fwinit 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +3430 -65
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,8 +5,8 @@ import { Command } from "commander";
5
5
  import ora from "ora";
6
6
  import chalk from "chalk";
7
7
  import { readFileSync } from "fs";
8
- import { mkdir as mkdir3, cp as cp3, readFile as readFile3 } from "fs/promises";
9
- import { join as join3, basename as basename2 } from "path";
8
+ import { mkdir as mkdir3, cp as cp3, readFile as readFile5 } from "fs/promises";
9
+ import { join as join6, basename as basename2 } from "path";
10
10
 
11
11
  // src/templates.ts
12
12
  var TEMPLATES = [
@@ -656,6 +656,13 @@ var AGENT_BUNDLE = [
656
656
  {
657
657
  // Biblioteca de estilos: skills/design/ con una subskill por estilo y sus
658
658
  // commands (/design, /design-<estilo>) que leen specs/frontend/02-design.md.
659
+ // Adentro vive también el catálogo search-driven ui-ux-pro-max (Python
660
+ // stdlib, offline): no es un estilo fijo sino una base consultable que
661
+ // genera un design system, pero comparte familia, carpeta y comando
662
+ // (/design-ui-ux-pro-max). Como la biblioteca se copia entera, el catálogo
663
+ // viaja con su data/ y sus scripts/ — si sólo viajara su SKILL.md, cada
664
+ // búsqueda devolvería 0 resultados y el agente caería a defaults genéricos
665
+ // sin avisar.
659
666
  kind: "library",
660
667
  dir: "design",
661
668
  required: false
@@ -672,33 +679,33 @@ async function exists(path) {
672
679
  }
673
680
  async function listMarkdown(dir) {
674
681
  try {
675
- const { readdir: readdir3 } = await import("fs/promises");
676
- return (await readdir3(dir)).filter((f) => f.endsWith(".md"));
682
+ const { readdir: readdir5 } = await import("fs/promises");
683
+ return (await readdir5(dir)).filter((f) => f.endsWith(".md"));
677
684
  } catch {
678
685
  return [];
679
686
  }
680
687
  }
681
688
  async function planAgentBundle(repoRoot, bundle = AGENT_BUNDLE) {
682
- const { join: join4 } = await import("path");
689
+ const { join: join7 } = await import("path");
683
690
  const installs = [];
684
691
  const skillDirs = [];
685
692
  const missingRequired = [];
686
693
  for (const entry of bundle) {
687
- const src = join4(repoRoot, "skills", entry.dir);
694
+ const src = join7(repoRoot, "skills", entry.dir);
688
695
  if (entry.kind === "library") {
689
696
  if (!await exists(src)) {
690
697
  if (entry.required) missingRequired.push(entry.dir);
691
698
  continue;
692
699
  }
693
700
  installs.push({ from: src, to: `skills/${entry.dir}`, recursive: true });
694
- const cmds = join4(src, "commands");
701
+ const cmds = join7(src, "commands");
695
702
  for (const file of await listMarkdown(cmds)) {
696
- installs.push({ from: join4(cmds, file), to: `commands/${file}` });
703
+ installs.push({ from: join7(cmds, file), to: `commands/${file}` });
697
704
  }
698
705
  continue;
699
706
  }
700
- const skill = join4(src, "SKILL.md");
701
- const command = join4(src, "command.md");
707
+ const skill = join7(src, "SKILL.md");
708
+ const command = join7(src, "command.md");
702
709
  if (!await exists(skill)) {
703
710
  if (entry.required) missingRequired.push(entry.dir);
704
711
  continue;
@@ -711,17 +718,3316 @@ async function planAgentBundle(repoRoot, bundle = AGENT_BUNDLE) {
711
718
  missingRequired.push(`${entry.dir}/command.md`);
712
719
  }
713
720
  for (const extra of entry.extras ?? []) {
714
- const from = join4(src, extra);
715
- if (await exists(from)) {
716
- installs.push({
717
- from,
718
- to: `skills/${entry.dir}/${extra}`,
719
- recursive: true
721
+ const from = join7(src, extra);
722
+ if (!await exists(from)) continue;
723
+ installs.push({
724
+ from,
725
+ to: `skills/${entry.dir}/${extra}`,
726
+ recursive: true
727
+ });
728
+ }
729
+ }
730
+ return { installs, skillDirs, commandDirs: ["commands"], missingRequired };
731
+ }
732
+
733
+ // src/studio/server.ts
734
+ import { createServer } from "http";
735
+ import { join as join4, resolve as resolve3 } from "path";
736
+
737
+ // src/studio/specs.ts
738
+ import { readdir as readdir2, readFile as readFile2, realpath, stat as stat2 } from "fs/promises";
739
+ import { extname as extname2, join as join2, relative as relative2, resolve, sep as sep2 } from "path";
740
+
741
+ // src/studio/markdown.ts
742
+ var ESCAPES = {
743
+ "&": "&amp;",
744
+ "<": "&lt;",
745
+ ">": "&gt;",
746
+ '"': "&quot;",
747
+ "'": "&#39;"
748
+ };
749
+ function esc(text) {
750
+ return text.replace(/[&<>"']/g, (char) => ESCAPES[char]);
751
+ }
752
+ function slugify(text) {
753
+ const slug = text.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/[\s-]+/g, "-").replace(/^-+|-+$/g, "");
754
+ return slug === "" ? "section" : slug;
755
+ }
756
+ function plainText(source) {
757
+ return source.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/\*\*\*|\*\*|\*|___|__|_|~~/g, "").trim();
758
+ }
759
+ function matchLink(source, start) {
760
+ const close = source.indexOf("]", start + 1);
761
+ if (close === -1) return null;
762
+ if (source[close + 1] !== "(") return null;
763
+ const destination = matchDestination(source, close + 1);
764
+ if (destination === null) return null;
765
+ return {
766
+ text: source.slice(start + 1, close),
767
+ target: destination.target,
768
+ end: destination.end
769
+ };
770
+ }
771
+ function matchDestination(source, openParen) {
772
+ let depth = 1;
773
+ let index = openParen + 1;
774
+ while (index < source.length) {
775
+ const char = source[index];
776
+ if (char === "(") depth += 1;
777
+ else if (char === ")") {
778
+ depth -= 1;
779
+ if (depth === 0) {
780
+ return { target: source.slice(openParen + 1, index), end: index + 1 };
781
+ }
782
+ }
783
+ index += 1;
784
+ }
785
+ return null;
786
+ }
787
+ var SAFE_PROTOCOL = /^(?:https?|mailto):/i;
788
+ var ANY_PROTOCOL = /^[a-z][a-z0-9+.-]*:/i;
789
+ var SPEC_PATH = /\.(?:md|markdown)$/i;
790
+ function normalizeSpecPath(target) {
791
+ const path = target.split("#")[0].split("?")[0].replace(/\\/g, "/");
792
+ const segments = [];
793
+ for (const segment of path.split("/")) {
794
+ if (segment === "" || segment === ".") continue;
795
+ segments.push(segment);
796
+ }
797
+ return segments.join("/");
798
+ }
799
+ function renderLink(text, rawTarget) {
800
+ const target = rawTarget.trim();
801
+ const inner = renderInline(text);
802
+ if (SAFE_PROTOCOL.test(target)) {
803
+ return `<a href="${esc(target)}" rel="noreferrer noopener">${inner}</a>`;
804
+ }
805
+ if (ANY_PROTOCOL.test(target)) return inner;
806
+ if (target.startsWith("/") || target.startsWith("\\")) return inner;
807
+ const specPath = normalizeSpecPath(target);
808
+ if (SPEC_PATH.test(specPath)) {
809
+ return `<a href="#" data-spec-link="${esc(specPath)}">${inner}</a>`;
810
+ }
811
+ return inner;
812
+ }
813
+ var WORD_CHAR = /[\p{L}\p{N}]/u;
814
+ function findEmphasisClose(source, char, size, from) {
815
+ let index = from;
816
+ while (index < source.length) {
817
+ if (source[index] !== char) {
818
+ index += 1;
819
+ continue;
820
+ }
821
+ let length = 0;
822
+ while (source[index + length] === char) length += 1;
823
+ if (length >= size) {
824
+ if (char === "_" && WORD_CHAR.test(source[index + length] ?? "")) {
825
+ index += length;
826
+ continue;
827
+ }
828
+ return { start: index, length };
829
+ }
830
+ index += length;
831
+ }
832
+ return null;
833
+ }
834
+ function matchEmphasis(source, start) {
835
+ const char = source[start];
836
+ if (char === "~") {
837
+ if (source[start + 1] !== "~") return null;
838
+ const close = source.indexOf("~~", start + 2);
839
+ if (close <= start + 2) return null;
840
+ return {
841
+ html: `<del>${renderInline(source.slice(start + 2, close))}</del>`,
842
+ end: close + 2
843
+ };
844
+ }
845
+ let run = 0;
846
+ while (source[start + run] === char && run < 3) run += 1;
847
+ if (char === "_" && start > 0 && WORD_CHAR.test(source[start - 1])) return null;
848
+ for (let size = run; size >= 1; size -= 1) {
849
+ const close = findEmphasisClose(source, char, size, start + size);
850
+ if (close === null) continue;
851
+ if (close.start === start + size) continue;
852
+ const extra = close.length - size;
853
+ const content = source.slice(start + size, close.start + extra);
854
+ const inner = renderInline(content);
855
+ if (size === 3) {
856
+ return { html: `<strong><em>${inner}</em></strong>`, end: close.start + close.length };
857
+ }
858
+ if (size === 2) {
859
+ return { html: `<strong>${inner}</strong>`, end: close.start + close.length };
860
+ }
861
+ return { html: `<em>${inner}</em>`, end: close.start + close.length };
862
+ }
863
+ return null;
864
+ }
865
+ function renderInline(source) {
866
+ let html = "";
867
+ let index = 0;
868
+ while (index < source.length) {
869
+ const char = source[index];
870
+ if (char === "`") {
871
+ const close = source.indexOf("`", index + 1);
872
+ if (close > index + 1) {
873
+ html += `<code>${esc(source.slice(index + 1, close))}</code>`;
874
+ index = close + 1;
875
+ continue;
876
+ }
877
+ }
878
+ if (char === "!" && source[index + 1] === "[") {
879
+ const image = matchLink(source, index + 1);
880
+ if (image !== null) {
881
+ html += `<span class="md-img">${renderInline(image.text)}</span>`;
882
+ index = image.end;
883
+ continue;
884
+ }
885
+ }
886
+ if (char === "[") {
887
+ const link = matchLink(source, index);
888
+ if (link !== null) {
889
+ html += renderLink(link.text, link.target);
890
+ index = link.end;
891
+ continue;
892
+ }
893
+ }
894
+ if (char === "*" || char === "_" || char === "~") {
895
+ const emphasis = matchEmphasis(source, index);
896
+ if (emphasis !== null) {
897
+ html += emphasis.html;
898
+ index = emphasis.end;
899
+ continue;
900
+ }
901
+ }
902
+ html += esc(char);
903
+ index += 1;
904
+ }
905
+ return html;
906
+ }
907
+ var LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
908
+ var FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*([^`]*)$/;
909
+ var HEADING = /^(#{1,6})\s+(.*)$/;
910
+ var RULE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
911
+ var QUOTE = /^\s{0,3}>/;
912
+ function tableCells(line) {
913
+ let trimmed = line.trim();
914
+ if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
915
+ if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
916
+ return trimmed.split("|").map((cell) => cell.trim());
917
+ }
918
+ function isTableSeparator(line) {
919
+ if (!line.includes("|")) return false;
920
+ const cells = tableCells(line);
921
+ return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
922
+ }
923
+ function isBlockStart(lines, index) {
924
+ const line = lines[index];
925
+ if (line.trim() === "") return true;
926
+ if (FENCE.test(line)) return true;
927
+ if (HEADING.test(line)) return true;
928
+ if (RULE.test(line)) return true;
929
+ if (QUOTE.test(line)) return true;
930
+ if (LIST_ITEM.test(line)) return true;
931
+ if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
932
+ return true;
933
+ }
934
+ return false;
935
+ }
936
+ function renderMarkdown(markdown) {
937
+ const headings = [];
938
+ const usedIds = /* @__PURE__ */ new Map();
939
+ const uniqueId = (base) => {
940
+ const seen = usedIds.get(base) ?? 0;
941
+ usedIds.set(base, seen + 1);
942
+ return seen === 0 ? base : `${base}-${seen + 1}`;
943
+ };
944
+ const renderItem = (item) => {
945
+ const task = /^\[([ xX])\]\s+(.*)$/.exec(item.text);
946
+ if (task !== null) {
947
+ const checked = task[1].toLowerCase() === "x";
948
+ const box = `<input type="checkbox" disabled${checked ? " checked" : ""}>`;
949
+ return { html: `${box} ${renderInline(task[2])}`, task: true };
950
+ }
951
+ return { html: renderInline(item.text), task: false };
952
+ };
953
+ const renderItems = (items, ordered) => {
954
+ const tag = ordered ? "ol" : "ul";
955
+ const parts = items.map((item) => {
956
+ const { html, task } = renderItem(item);
957
+ const cls = task ? ' class="task"' : "";
958
+ return `<li${cls}>${html}</li>`;
959
+ });
960
+ return `<${tag}>${parts.join("")}</${tag}>`;
961
+ };
962
+ const renderBlocks = (lines) => {
963
+ const out = [];
964
+ let index = 0;
965
+ while (index < lines.length) {
966
+ const line = lines[index];
967
+ if (line.trim() === "") {
968
+ index += 1;
969
+ continue;
970
+ }
971
+ const fence = FENCE.exec(line);
972
+ if (fence !== null) {
973
+ const marker = fence[1];
974
+ const fenceChar = marker[0];
975
+ const lang = fence[2].trim().split(/\s+/)[0] ?? "";
976
+ const body = [];
977
+ index += 1;
978
+ while (index < lines.length) {
979
+ const candidate = lines[index];
980
+ const trimmed = candidate.trim();
981
+ const closes = trimmed.length >= marker.length && trimmed.split("").every((char) => char === fenceChar);
982
+ if (closes) {
983
+ index += 1;
984
+ break;
985
+ }
986
+ body.push(candidate);
987
+ index += 1;
988
+ }
989
+ const cls = lang === "" ? "" : ` class="lang-${esc(lang)}"`;
990
+ out.push(`<pre><code${cls}>${esc(body.join("\n"))}</code></pre>`);
991
+ continue;
992
+ }
993
+ const heading = HEADING.exec(line);
994
+ if (heading !== null) {
995
+ const level = heading[1].length;
996
+ const raw = heading[2].replace(/\s+#+\s*$/, "").trim();
997
+ const text = plainText(raw);
998
+ const id = uniqueId(slugify(text));
999
+ headings.push({ level, text, id });
1000
+ out.push(`<h${level} id="${esc(id)}">${renderInline(raw)}</h${level}>`);
1001
+ index += 1;
1002
+ continue;
1003
+ }
1004
+ if (RULE.test(line)) {
1005
+ out.push("<hr>");
1006
+ index += 1;
1007
+ continue;
1008
+ }
1009
+ if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
1010
+ const header = tableCells(line);
1011
+ const rows = [];
1012
+ index += 2;
1013
+ while (index < lines.length && lines[index].trim() !== "" && lines[index].includes("|")) {
1014
+ rows.push(tableCells(lines[index]));
1015
+ index += 1;
1016
+ }
1017
+ const head = header.map((cell) => `<th>${renderInline(cell)}</th>`).join("");
1018
+ const body = rows.map(
1019
+ (row) => `<tr>${row.map((cell) => `<td>${renderInline(cell)}</td>`).join("")}</tr>`
1020
+ ).join("");
1021
+ out.push(
1022
+ `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`
1023
+ );
1024
+ continue;
1025
+ }
1026
+ if (QUOTE.test(line)) {
1027
+ const quoted = [];
1028
+ while (index < lines.length && QUOTE.test(lines[index])) {
1029
+ quoted.push(lines[index].replace(/^\s{0,3}>\s?/, ""));
1030
+ index += 1;
1031
+ }
1032
+ out.push(`<blockquote>${renderBlocks(quoted)}</blockquote>`);
1033
+ continue;
1034
+ }
1035
+ if (LIST_ITEM.test(line)) {
1036
+ const items = [];
1037
+ let baseIndent = -1;
1038
+ while (index < lines.length) {
1039
+ const match = LIST_ITEM.exec(lines[index]);
1040
+ if (match === null) break;
1041
+ const indent = match[1].length;
1042
+ if (baseIndent === -1) baseIndent = indent;
1043
+ items.push({
1044
+ indent,
1045
+ ordered: /\d/.test(match[2]),
1046
+ text: match[3]
1047
+ });
1048
+ index += 1;
1049
+ }
1050
+ const top = [];
1051
+ for (const item of items) {
1052
+ if (item.indent > baseIndent && top.length > 0) {
1053
+ top[top.length - 1].children.push(item);
1054
+ } else {
1055
+ top.push({ item, children: [] });
1056
+ }
1057
+ }
1058
+ const ordered = top[0].item.ordered;
1059
+ const lis = top.map(({ item, children }) => {
1060
+ const { html, task } = renderItem(item);
1061
+ const cls = task ? ' class="task"' : "";
1062
+ const nested = children.length > 0 ? renderItems(children, children[0].ordered) : "";
1063
+ return `<li${cls}>${html}${nested}</li>`;
1064
+ });
1065
+ out.push(`<${ordered ? "ol" : "ul"}>${lis.join("")}</${ordered ? "ol" : "ul"}>`);
1066
+ continue;
1067
+ }
1068
+ const paragraph = [];
1069
+ const start = index;
1070
+ while (index < lines.length) {
1071
+ const current = lines[index];
1072
+ if (current.trim() === "") break;
1073
+ if (index > start && isBlockStart(lines, index)) break;
1074
+ paragraph.push(current.trim());
1075
+ index += 1;
1076
+ }
1077
+ out.push(`<p>${renderInline(paragraph.join("\n"))}</p>`);
1078
+ }
1079
+ return out.join("");
1080
+ };
1081
+ return { html: renderBlocks(markdown.replace(/\r\n?/g, "\n").split("\n")), headings };
1082
+ }
1083
+
1084
+ // src/studio/specs.ts
1085
+ var SKIPPED_NAMES = /* @__PURE__ */ new Set([".git", "node_modules"]);
1086
+ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown", ".txt"]);
1087
+ var SpecPathError = class extends Error {
1088
+ code;
1089
+ path;
1090
+ constructor(code, path, message) {
1091
+ super(message);
1092
+ this.name = "SpecPathError";
1093
+ this.code = code;
1094
+ this.path = path;
1095
+ }
1096
+ };
1097
+ function isInside(root, candidate) {
1098
+ if (candidate === root) return true;
1099
+ const prefix = root.endsWith(sep2) ? root : `${root}${sep2}`;
1100
+ return candidate.startsWith(prefix);
1101
+ }
1102
+ async function realpathOrNull(path) {
1103
+ try {
1104
+ return await realpath(path);
1105
+ } catch {
1106
+ return null;
1107
+ }
1108
+ }
1109
+ function compareNodes(a, b) {
1110
+ const left = a.name.toLowerCase();
1111
+ const right = b.name.toLowerCase();
1112
+ const byName = left.localeCompare(right);
1113
+ return byName !== 0 ? byName : a.name.localeCompare(b.name);
1114
+ }
1115
+ async function scanDirectory(absoluteDir, relativeDir, totals) {
1116
+ let entries;
1117
+ try {
1118
+ entries = await readdir2(absoluteDir, { withFileTypes: true });
1119
+ } catch {
1120
+ return [];
1121
+ }
1122
+ const dirs = [];
1123
+ const files = [];
1124
+ for (const entry of entries) {
1125
+ if (entry.isSymbolicLink()) continue;
1126
+ if (entry.name.startsWith(".") || SKIPPED_NAMES.has(entry.name)) continue;
1127
+ const childPath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
1128
+ if (entry.isDirectory()) {
1129
+ const children = await scanDirectory(join2(absoluteDir, entry.name), childPath, totals);
1130
+ totals.dirs += 1;
1131
+ dirs.push({ name: entry.name, path: childPath, type: "dir", children });
1132
+ continue;
1133
+ }
1134
+ if (!entry.isFile()) continue;
1135
+ let info;
1136
+ try {
1137
+ info = await stat2(join2(absoluteDir, entry.name));
1138
+ } catch {
1139
+ continue;
1140
+ }
1141
+ totals.files += 1;
1142
+ totals.bytes += info.size;
1143
+ files.push({
1144
+ name: entry.name,
1145
+ path: childPath,
1146
+ type: "file",
1147
+ bytes: info.size,
1148
+ modified: info.mtime.toISOString()
1149
+ });
1150
+ }
1151
+ dirs.sort(compareNodes);
1152
+ files.sort(compareNodes);
1153
+ return [...dirs, ...files];
1154
+ }
1155
+ async function buildSpecsTree(projectRoot) {
1156
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
1157
+ const specsRoot = join2(projectRoot, "specs");
1158
+ const empty = {
1159
+ found: false,
1160
+ root: null,
1161
+ projectRoot,
1162
+ sections: [],
1163
+ tree: [],
1164
+ totals: { files: 0, dirs: 0, bytes: 0 },
1165
+ generatedAt
1166
+ };
1167
+ let rootInfo;
1168
+ try {
1169
+ rootInfo = await stat2(specsRoot);
1170
+ } catch {
1171
+ return empty;
1172
+ }
1173
+ if (!rootInfo.isDirectory()) return empty;
1174
+ const totals = { files: 0, dirs: 0, bytes: 0 };
1175
+ const tree = await scanDirectory(specsRoot, "", totals);
1176
+ const sections = tree.filter((node) => node.type === "dir").map((node) => node.name);
1177
+ return {
1178
+ found: true,
1179
+ root: specsRoot,
1180
+ projectRoot,
1181
+ sections,
1182
+ tree,
1183
+ totals,
1184
+ generatedAt
1185
+ };
1186
+ }
1187
+ function frontmatterEndIndex(lines) {
1188
+ if (lines[0]?.trim() !== "---") return -1;
1189
+ return lines.findIndex(
1190
+ (line, index) => index > 0 && (line.trim() === "---" || line.trim() === "...")
1191
+ );
1192
+ }
1193
+ function stripFrontmatter(markdown) {
1194
+ const lines = markdown.split(/\r?\n/);
1195
+ const end = frontmatterEndIndex(lines);
1196
+ if (end === -1) return markdown;
1197
+ return lines.slice(end + 1).join("\n");
1198
+ }
1199
+ function collectWarnings(markdown) {
1200
+ const warnings = [];
1201
+ const lines = markdown.split(/\r?\n/);
1202
+ if (lines[0]?.trim() === "---" && frontmatterEndIndex(lines) === -1) {
1203
+ warnings.push("El bloque de frontmatter no se cierra (falta `---`).");
1204
+ }
1205
+ return warnings;
1206
+ }
1207
+ function assertRelativePath(requested) {
1208
+ if (requested.trim() === "") {
1209
+ throw new SpecPathError("invalid", requested, "La ruta de la spec est\xE1 vac\xEDa.");
1210
+ }
1211
+ if (requested.includes("\0")) {
1212
+ throw new SpecPathError("invalid", requested, "La ruta de la spec contiene un NUL.");
1213
+ }
1214
+ if (requested.startsWith("/") || requested.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(requested)) {
1215
+ throw new SpecPathError(
1216
+ "invalid",
1217
+ requested,
1218
+ "La ruta de la spec debe ser relativa a specs/."
1219
+ );
1220
+ }
1221
+ return requested.split(/[\\/]+/).filter((segment) => segment !== "" && segment !== ".");
1222
+ }
1223
+ function resolveSpecLink(baseDir, target) {
1224
+ const stack = baseDir.filter((segment) => segment !== "");
1225
+ for (const segment of target.split(/[\\/]+/)) {
1226
+ if (segment === "" || segment === ".") continue;
1227
+ if (segment === "..") {
1228
+ stack.pop();
1229
+ continue;
1230
+ }
1231
+ stack.push(segment);
1232
+ }
1233
+ return stack.join("/");
1234
+ }
1235
+ function normalizeSpecLinks(html, basePath) {
1236
+ const baseDir = basePath.split("/").slice(0, -1);
1237
+ return html.replace(/data-spec-link="([^"]*)"/g, (_match, target) => {
1238
+ return `data-spec-link="${resolveSpecLink(baseDir, target)}"`;
1239
+ });
1240
+ }
1241
+ async function readSpecFile(roots, relativePath) {
1242
+ const segments = assertRelativePath(relativePath);
1243
+ const logicalRoot = resolve(roots.specsRoot);
1244
+ const logical = resolve(logicalRoot, relativePath);
1245
+ if (!isInside(logicalRoot, logical)) {
1246
+ throw new SpecPathError("outside", relativePath, "La spec pedida est\xE1 fuera de specs/.");
1247
+ }
1248
+ if (segments.includes("..")) {
1249
+ throw new SpecPathError(
1250
+ "invalid",
1251
+ relativePath,
1252
+ "La ruta de la spec no puede contener segmentos '..'."
1253
+ );
1254
+ }
1255
+ const realRoot = await realpathOrNull(logicalRoot);
1256
+ if (realRoot === null) {
1257
+ throw new SpecPathError("missing", relativePath, "El proyecto no tiene carpeta specs/.");
1258
+ }
1259
+ const real = await realpathOrNull(logical);
1260
+ if (real === null) {
1261
+ throw new SpecPathError("missing", relativePath, `No existe la spec ${relativePath}.`);
1262
+ }
1263
+ if (!isInside(realRoot, real)) {
1264
+ throw new SpecPathError("outside", relativePath, "La spec resuelve fuera de specs/.");
1265
+ }
1266
+ const info = await stat2(real).catch(() => null);
1267
+ if (info === null) {
1268
+ throw new SpecPathError("missing", relativePath, `No existe la spec ${relativePath}.`);
1269
+ }
1270
+ if (!info.isFile()) {
1271
+ throw new SpecPathError("unsupported", relativePath, "La spec no es un archivo regular.");
1272
+ }
1273
+ const extension = extname2(real).toLowerCase();
1274
+ if (!SUPPORTED_EXTENSIONS.has(extension)) {
1275
+ throw new SpecPathError(
1276
+ "unsupported",
1277
+ relativePath,
1278
+ `Extensi\xF3n no soportada (${extension || "sin extensi\xF3n"}); us\xE1 .md, .markdown o .txt.`
1279
+ );
1280
+ }
1281
+ const markdown = await readFile2(real, "utf8").catch(() => null);
1282
+ if (markdown === null) {
1283
+ throw new SpecPathError("missing", relativePath, `No se pudo leer ${relativePath}.`);
1284
+ }
1285
+ const { html: rawHtml, headings } = renderMarkdown(stripFrontmatter(markdown));
1286
+ const level1 = headings.find((heading) => heading.level === 1);
1287
+ const posixPath = relative2(logicalRoot, logical).split(sep2).join("/");
1288
+ const html = normalizeSpecLinks(rawHtml, posixPath);
1289
+ return {
1290
+ path: posixPath,
1291
+ absolutePath: logical,
1292
+ title: level1?.text || posixPath.split("/").pop() || posixPath,
1293
+ markdown,
1294
+ html,
1295
+ headings,
1296
+ bytes: info.size,
1297
+ modified: info.mtime.toISOString(),
1298
+ warnings: collectWarnings(markdown)
1299
+ };
1300
+ }
1301
+
1302
+ // src/studio/skills.ts
1303
+ import { readdir as readdir3, readFile as readFile3, stat as stat3 } from "fs/promises";
1304
+ import { homedir } from "os";
1305
+ import { join as join3, resolve as resolve2 } from "path";
1306
+
1307
+ // src/studio/frontmatter.ts
1308
+ var DELIMITER = "---";
1309
+ var KEY_VALUE = /^([A-Za-z0-9_.-]+)[ \t]*:[ \t]*(.*)$/;
1310
+ var MAX_LINE_IN_WARNING = 80;
1311
+ function splitLines(source) {
1312
+ return source.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1313
+ }
1314
+ function stripQuotes(value) {
1315
+ if (value.length < 2) return value;
1316
+ const first = value[0];
1317
+ const last = value[value.length - 1];
1318
+ if ((first === '"' || first === "'") && first === last) {
1319
+ return value.slice(1, -1);
1320
+ }
1321
+ return value;
1322
+ }
1323
+ function malformed(reason, body) {
1324
+ return { data: {}, warnings: [`malformed frontmatter: ${reason}`], body };
1325
+ }
1326
+ function parseFrontmatter(source) {
1327
+ const lines = splitLines(source);
1328
+ if ((lines[0] ?? "").trimEnd() !== DELIMITER) {
1329
+ return { data: {}, warnings: [], body: source };
1330
+ }
1331
+ let closing = -1;
1332
+ for (let index = 1; index < lines.length; index++) {
1333
+ if (lines[index].trimEnd() === DELIMITER) {
1334
+ closing = index;
1335
+ break;
1336
+ }
1337
+ }
1338
+ if (closing === -1) {
1339
+ return {
1340
+ data: {},
1341
+ warnings: ["frontmatter: block is unterminated"],
1342
+ body: ""
1343
+ };
1344
+ }
1345
+ const data = {};
1346
+ const body = lines.slice(closing + 1).join("\n");
1347
+ let parent = null;
1348
+ for (const raw of lines.slice(1, closing)) {
1349
+ if (!raw.trim()) continue;
1350
+ if (raw.trimStart().startsWith("#")) continue;
1351
+ const indented = /^[ \t]/.test(raw);
1352
+ const match = KEY_VALUE.exec(raw.trim());
1353
+ if (!match) {
1354
+ return malformed(
1355
+ `line is not "key: value": ${raw.trim().slice(0, MAX_LINE_IN_WARNING)}`,
1356
+ body
1357
+ );
1358
+ }
1359
+ const key = match[1];
1360
+ const rawValue = match[2].trim();
1361
+ const value = stripQuotes(rawValue);
1362
+ if (indented) {
1363
+ if (parent === null) {
1364
+ return malformed(`indented key without a parent block: ${key}`, body);
1365
+ }
1366
+ if (rawValue === "") {
1367
+ return malformed(`nested key has no value: ${parent}.${key}`, body);
1368
+ }
1369
+ data[`${parent}.${key}`] = value;
1370
+ continue;
1371
+ }
1372
+ if (rawValue === "") {
1373
+ parent = key;
1374
+ continue;
1375
+ }
1376
+ parent = null;
1377
+ data[key] = value;
1378
+ }
1379
+ return { data, warnings: [], body };
1380
+ }
1381
+
1382
+ // src/studio/skills.ts
1383
+ var ENTRY_FILE = "skill.md";
1384
+ var COMMAND_FILE = "command.md";
1385
+ function isMissing(error) {
1386
+ const code = error?.code;
1387
+ return code === "ENOENT" || code === "ENOTDIR";
1388
+ }
1389
+ function resolveSkillTargets(options) {
1390
+ const home = resolve2(options.home ?? homedir());
1391
+ const projectRoot = resolve2(options.projectRoot);
1392
+ const gentleShellRoot = process.env.GENTLE_SHELL_HOME?.trim() || join3(home, ".gentle-shell", "agent");
1393
+ return [
1394
+ {
1395
+ id: "opencode",
1396
+ label: "opencode",
1397
+ globalDir: join3(home, ".config", "opencode", "skills"),
1398
+ projectDir: join3(projectRoot, ".opencode", "skills")
1399
+ },
1400
+ {
1401
+ id: "pi",
1402
+ label: "pi",
1403
+ globalDir: join3(home, ".pi", "agent", "skills"),
1404
+ projectDir: join3(projectRoot, ".pi", "skills")
1405
+ },
1406
+ {
1407
+ id: "gentle-shell",
1408
+ label: "Gentle Shell",
1409
+ globalDir: join3(gentleShellRoot, "skills"),
1410
+ projectDir: join3(projectRoot, ".gentle-shell", "skills")
1411
+ }
1412
+ ];
1413
+ }
1414
+ function compareNames(a, b) {
1415
+ const left = a.toLowerCase();
1416
+ const right = b.toLowerCase();
1417
+ if (left < right) return -1;
1418
+ if (left > right) return 1;
1419
+ if (a < b) return -1;
1420
+ if (a > b) return 1;
1421
+ return 0;
1422
+ }
1423
+ function firstProseLine(body) {
1424
+ let inFence = false;
1425
+ for (const raw of body.split(/\r?\n/)) {
1426
+ const line = raw.trim();
1427
+ if (!line) continue;
1428
+ if (/^(?:```|~~~)/.test(line)) {
1429
+ inFence = !inFence;
1430
+ continue;
1431
+ }
1432
+ if (inFence) continue;
1433
+ if (line.startsWith("#")) continue;
1434
+ if (line === "---") continue;
1435
+ return line;
1436
+ }
1437
+ return "";
1438
+ }
1439
+ async function measureDirectory(dir) {
1440
+ let files = 0;
1441
+ let bytes = 0;
1442
+ let entries;
1443
+ try {
1444
+ entries = await readdir3(dir, { withFileTypes: true });
1445
+ } catch {
1446
+ return { files, bytes };
1447
+ }
1448
+ for (const entry of entries) {
1449
+ if (entry.isSymbolicLink()) continue;
1450
+ const path = join3(dir, entry.name);
1451
+ if (entry.isDirectory()) {
1452
+ const nested = await measureDirectory(path);
1453
+ files += nested.files;
1454
+ bytes += nested.bytes;
1455
+ continue;
1456
+ }
1457
+ if (!entry.isFile()) continue;
1458
+ try {
1459
+ const info = await stat3(path);
1460
+ files += 1;
1461
+ bytes += info.size;
1462
+ } catch {
1463
+ }
1464
+ }
1465
+ return { files, bytes };
1466
+ }
1467
+ async function readSkill(dir, name, target, scope, warnings) {
1468
+ let entries;
1469
+ try {
1470
+ entries = await readdir3(dir, { withFileTypes: true });
1471
+ } catch {
1472
+ warnings.push(`cannot read skill directory: ${dir}`);
1473
+ return null;
1474
+ }
1475
+ const entryFile = entries.find(
1476
+ (entry) => entry.isFile() && entry.name.toLowerCase() === ENTRY_FILE
1477
+ );
1478
+ if (!entryFile) return null;
1479
+ const entryPath = join3(dir, entryFile.name);
1480
+ let source;
1481
+ let entryInfo;
1482
+ try {
1483
+ source = await readFile3(entryPath, "utf-8");
1484
+ entryInfo = await stat3(entryPath);
1485
+ } catch {
1486
+ warnings.push(`cannot read skill entry file: ${entryPath}`);
1487
+ return null;
1488
+ }
1489
+ const parsed = parseFrontmatter(source);
1490
+ const { files, bytes } = await measureDirectory(dir);
1491
+ const version = parsed.data["metadata.version"]?.trim();
1492
+ const author = parsed.data["metadata.author"]?.trim();
1493
+ const license = parsed.data["license"]?.trim();
1494
+ const description = parsed.data["description"]?.trim() || firstProseLine(parsed.body);
1495
+ return {
1496
+ name,
1497
+ description,
1498
+ version: version || void 0,
1499
+ license: license || void 0,
1500
+ author: author || void 0,
1501
+ dir,
1502
+ path: entryPath,
1503
+ target,
1504
+ scope,
1505
+ files,
1506
+ bytes,
1507
+ modified: entryInfo.mtime.toISOString(),
1508
+ hasCommand: entries.some(
1509
+ (entry) => entry.isFile() && entry.name.toLowerCase() === COMMAND_FILE
1510
+ ),
1511
+ warnings: parsed.warnings
1512
+ };
1513
+ }
1514
+ async function scanSkillsDir(dir, target, scope, warnings) {
1515
+ let info;
1516
+ try {
1517
+ info = await stat3(dir);
1518
+ } catch (error) {
1519
+ if (isMissing(error)) return { exists: false, skills: [] };
1520
+ warnings.push(`cannot read skills directory: ${dir}`);
1521
+ return { exists: false, skills: [] };
1522
+ }
1523
+ if (!info.isDirectory()) return { exists: false, skills: [] };
1524
+ let entries;
1525
+ try {
1526
+ entries = await readdir3(dir, { withFileTypes: true });
1527
+ } catch {
1528
+ warnings.push(`cannot list skills directory: ${dir}`);
1529
+ return { exists: true, skills: [] };
1530
+ }
1531
+ const skills = [];
1532
+ for (const entry of entries) {
1533
+ if (entry.isSymbolicLink()) {
1534
+ warnings.push(`skipped symlinked skill directory: ${join3(dir, entry.name)}`);
1535
+ continue;
1536
+ }
1537
+ if (!entry.isDirectory()) continue;
1538
+ const skill = await readSkill(
1539
+ join3(dir, entry.name),
1540
+ entry.name,
1541
+ target,
1542
+ scope,
1543
+ warnings
1544
+ );
1545
+ if (skill) skills.push(skill);
1546
+ }
1547
+ skills.sort((a, b) => compareNames(a.name, b.name));
1548
+ return { exists: true, skills };
1549
+ }
1550
+ async function discoverSkills(options) {
1551
+ const projectRoot = resolve2(options.projectRoot);
1552
+ const warnings = [];
1553
+ const targets = [];
1554
+ const totals = { global: 0, project: 0 };
1555
+ for (const spec of resolveSkillTargets(options)) {
1556
+ const globalScan = await scanSkillsDir(
1557
+ spec.globalDir,
1558
+ spec.id,
1559
+ "global",
1560
+ warnings
1561
+ );
1562
+ const projectScan = await scanSkillsDir(
1563
+ spec.projectDir,
1564
+ spec.id,
1565
+ "project",
1566
+ warnings
1567
+ );
1568
+ totals.global += globalScan.skills.length;
1569
+ totals.project += projectScan.skills.length;
1570
+ const skills = [...globalScan.skills, ...projectScan.skills].sort(
1571
+ (a, b) => compareNames(a.name, b.name)
1572
+ );
1573
+ targets.push({
1574
+ id: spec.id,
1575
+ label: spec.label,
1576
+ globalDir: spec.globalDir,
1577
+ projectDir: spec.projectDir,
1578
+ globalExists: globalScan.exists,
1579
+ projectExists: projectScan.exists,
1580
+ skills
1581
+ });
1582
+ }
1583
+ return {
1584
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1585
+ projectRoot,
1586
+ targets,
1587
+ totals,
1588
+ warnings
1589
+ };
1590
+ }
1591
+
1592
+ // src/studio/ui/client.ts
1593
+ var CLIENT_JS = `
1594
+ (function () {
1595
+ 'use strict';
1596
+
1597
+ var THEME_KEY = 'fwinit:theme';
1598
+ var metaEl = document.getElementById('studio-meta');
1599
+ var meta = metaEl ? JSON.parse(metaEl.textContent || '{}') : {};
1600
+
1601
+ var state = {
1602
+ view: 'skills',
1603
+ scope: 'global',
1604
+ skillKey: null,
1605
+ specPath: null,
1606
+ filter: { skills: '', specs: '' },
1607
+ skills: null,
1608
+ specs: null,
1609
+ doc: null,
1610
+ collapsed: {},
1611
+ expanded: {},
1612
+ tocLinks: []
1613
+ };
1614
+
1615
+ function q(sel, root) {
1616
+ return (root || document).querySelector(sel);
1617
+ }
1618
+
1619
+ function qa(sel, root) {
1620
+ return Array.prototype.slice.call((root || document).querySelectorAll(sel));
1621
+ }
1622
+
1623
+ function el(tag, props, children) {
1624
+ var node = document.createElement(tag);
1625
+ if (props) {
1626
+ Object.keys(props).forEach(function (key) {
1627
+ var value = props[key];
1628
+ if (value === undefined || value === null || value === false) return;
1629
+ if (key === 'class') node.className = value;
1630
+ else if (key === 'text') node.textContent = String(value);
1631
+ else if (key === 'dataset') {
1632
+ Object.keys(value).forEach(function (dataKey) {
1633
+ node.dataset[dataKey] = String(value[dataKey]);
1634
+ });
1635
+ } else if (key.indexOf('on') === 0 && typeof value === 'function') {
1636
+ node.addEventListener(key.slice(2).toLowerCase(), value);
1637
+ } else if (key === 'html') {
1638
+ node.innerHTML = value;
1639
+ } else {
1640
+ node.setAttribute(key, String(value));
1641
+ }
1642
+ });
1643
+ }
1644
+ (children || []).forEach(function (child) {
1645
+ if (child === null || child === undefined || child === false) return;
1646
+ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
1647
+ });
1648
+ return node;
1649
+ }
1650
+
1651
+ function fmtBytes(bytes) {
1652
+ if (typeof bytes !== 'number' || !isFinite(bytes)) return '\u2014';
1653
+ if (bytes < 1024) return bytes + ' B';
1654
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
1655
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1656
+ }
1657
+
1658
+ function fmtDate(iso) {
1659
+ if (!iso) return '\u2014';
1660
+ var date = new Date(iso);
1661
+ if (isNaN(date.getTime())) return '\u2014';
1662
+ var pad = function (value) { return String(value).padStart(2, '0'); };
1663
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
1664
+ ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
1665
+ }
1666
+
1667
+ function shortPath(path) {
1668
+ var home = meta.home || '';
1669
+ if (home && path.indexOf(home) === 0) return '~' + path.slice(home.length);
1670
+ return path;
1671
+ }
1672
+
1673
+ function copyValue(value, button) {
1674
+ var run = function () {
1675
+ var done = function () {
1676
+ if (!button) return;
1677
+ var previous = button.getAttribute('data-label') || button.textContent;
1678
+ button.setAttribute('data-label', previous);
1679
+ button.setAttribute('data-copied', 'true');
1680
+ button.textContent = 'Copiado';
1681
+ setTimeout(function () {
1682
+ button.removeAttribute('data-copied');
1683
+ button.textContent = previous;
1684
+ }, 1400);
1685
+ };
1686
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1687
+ navigator.clipboard.writeText(value).then(done, function () {});
1688
+ return;
1689
+ }
1690
+ var area = el('textarea', { style: 'position:fixed;opacity:0' });
1691
+ area.value = value;
1692
+ document.body.appendChild(area);
1693
+ area.select();
1694
+ try { document.execCommand('copy'); done(); } catch (error) { /* ignore */ }
1695
+ document.body.removeChild(area);
1696
+ };
1697
+ run();
1698
+ }
1699
+
1700
+ function copyButton(label, value) {
1701
+ return el('button', {
1702
+ type: 'button',
1703
+ class: 'btn',
1704
+ 'aria-label': 'Copiar ' + label,
1705
+ text: label,
1706
+ onclick: function (event) { copyValue(value, event.currentTarget); }
1707
+ });
1708
+ }
1709
+
1710
+ /* \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1711
+
1712
+ function systemTheme() {
1713
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
1714
+ }
1715
+
1716
+ function currentTheme() {
1717
+ var stored = document.documentElement.dataset.theme;
1718
+ if (stored === 'light' || stored === 'dark') return stored;
1719
+ return systemTheme();
1720
+ }
1721
+
1722
+ function applyTheme(theme) {
1723
+ document.documentElement.dataset.theme = theme;
1724
+ try { localStorage.setItem(THEME_KEY, theme); } catch (error) { /* private mode */ }
1725
+ var button = q('[data-theme-toggle]');
1726
+ if (!button) return;
1727
+ var isLight = theme === 'light';
1728
+ button.setAttribute('aria-pressed', isLight ? 'true' : 'false');
1729
+ button.setAttribute('aria-label', isLight ? 'Cambiar al tema oscuro' : 'Cambiar al tema claro');
1730
+ }
1731
+
1732
+ function toggleTheme() {
1733
+ applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
1734
+ }
1735
+
1736
+ /* \u2500\u2500 Data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1737
+
1738
+ function fetchJson(url) {
1739
+ return fetch(url, { headers: { accept: 'application/json' } }).then(function (response) {
1740
+ if (response.ok) return response.json();
1741
+ return response.json().catch(function () { return {}; }).then(function (body) {
1742
+ var error = new Error(body.error || 'La solicitud fall\xF3 (' + response.status + ')');
1743
+ error.status = response.status;
1744
+ throw error;
1745
+ });
1746
+ });
1747
+ }
1748
+
1749
+ function loadSkills() {
1750
+ if (state.skills) return Promise.resolve(state.skills);
1751
+ return fetchJson('/api/skills').then(function (data) {
1752
+ state.skills = data;
1753
+ return data;
1754
+ });
1755
+ }
1756
+
1757
+ function loadSpecs() {
1758
+ if (state.specs) return Promise.resolve(state.specs);
1759
+ return fetchJson('/api/specs').then(function (data) {
1760
+ state.specs = data;
1761
+ return data;
1762
+ });
1763
+ }
1764
+
1765
+ /* \u2500\u2500 Routing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1766
+
1767
+ function route() {
1768
+ var raw = (location.hash || '#/skills').replace(/^#\\/?/, '');
1769
+ var parts = raw.split('/').map(function (part) {
1770
+ try { return decodeURIComponent(part); } catch (error) { return part; }
1771
+ });
1772
+ state.view = parts[0] === 'specs' ? 'specs' : 'skills';
1773
+ if (state.view === 'skills') {
1774
+ if (parts.length >= 4) {
1775
+ state.scope = parts[1] === 'project' ? 'project' : 'global';
1776
+ state.skillKey = parts.slice(2).join('/');
1777
+ }
1778
+ } else {
1779
+ var nextPath = parts.slice(1).join('/') || null;
1780
+ if (state.doc && state.doc.path !== nextPath) state.doc = null;
1781
+ state.specPath = nextPath;
1782
+ }
1783
+ render();
1784
+ // Cambiar de ruta tiene que pedir el documento: render() s\xF3lo dibuja el
1785
+ // estado que ya tiene en memoria.
1786
+ if (state.view === 'specs') loadSpecDoc();
1787
+ }
1788
+
1789
+ function navigate(hash) {
1790
+ if (location.hash === hash) render();
1791
+ else location.hash = hash;
1792
+ }
1793
+
1794
+ function skillHash(target, name) {
1795
+ return '#/skills/' + state.scope + '/' + encodeURIComponent(target) + '/' + encodeURIComponent(name);
1796
+ }
1797
+
1798
+ function specHash(path) {
1799
+ return '#/specs/' + path.split('/').map(encodeURIComponent).join('/');
1800
+ }
1801
+
1802
+ /* \u2500\u2500 Shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1803
+
1804
+ function syncShell() {
1805
+ qa('[data-view]').forEach(function (tab) {
1806
+ tab.setAttribute('aria-selected', tab.dataset.view === state.view ? 'true' : 'false');
1807
+ });
1808
+ qa('[data-view-panel]').forEach(function (panel) {
1809
+ panel.dataset.active = panel.dataset.viewPanel === state.view ? 'true' : 'false';
1810
+ });
1811
+ var skillsCount = q('[data-count="skills"]');
1812
+ var specsCount = q('[data-count="specs"]');
1813
+ if (skillsCount && state.skills) {
1814
+ skillsCount.textContent = String(state.skills.totals.global + state.skills.totals.project);
1815
+ }
1816
+ if (specsCount && state.specs) {
1817
+ specsCount.textContent = String(state.specs.totals.files);
1818
+ }
1819
+ var counts = q('[data-status-counts]');
1820
+ if (counts) {
1821
+ var bits = [];
1822
+ if (state.skills) bits.push(state.skills.totals.global + ' skills globales');
1823
+ if (state.skills) bits.push(state.skills.totals.project + ' skills del proyecto');
1824
+ if (state.specs) bits.push(state.specs.totals.files + ' archivos de specs');
1825
+ counts.textContent = bits.join(' \xB7 ');
1826
+ }
1827
+ var root = q('[data-status-root]');
1828
+ if (root) {
1829
+ root.textContent = shortPath(meta.projectRoot || '');
1830
+ root.title = meta.projectRoot || '';
1831
+ }
1832
+ }
1833
+
1834
+ function emptyState(options) {
1835
+ return el('div', { class: 'empty' }, [
1836
+ el('p', { class: 'empty__title', text: options.title }),
1837
+ options.text ? el('p', { class: 'empty__text', text: options.text }) : null,
1838
+ options.path ? el('p', { class: 'empty__path', text: options.path }) : null,
1839
+ options.hint ? el('p', { class: 'empty__hint', text: options.hint }) : null
1840
+ ]);
1841
+ }
1842
+
1843
+ function renderError(container, error) {
1844
+ container.textContent = '';
1845
+ container.appendChild(emptyState({
1846
+ title: 'El studio no pudo leer esto',
1847
+ text: error && error.message ? error.message : 'Error desconocido.',
1848
+ hint: 'Volv\xE9 a ejecutar fwinit studio desde la ra\xEDz del proyecto.'
1849
+ }));
1850
+ }
1851
+
1852
+ /* \u2500\u2500 Skills view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1853
+
1854
+ function visibleSkills(report, scope) {
1855
+ var filter = state.filter.skills.trim().toLowerCase();
1856
+ var out = [];
1857
+ (report.targets || []).forEach(function (target) {
1858
+ (target.skills || []).forEach(function (skill) {
1859
+ if (skill.scope !== scope) return;
1860
+ if (filter) {
1861
+ var haystack = (skill.name + ' ' + (skill.description || '') + ' ' + (skill.version || '')).toLowerCase();
1862
+ if (haystack.indexOf(filter) === -1) return;
1863
+ }
1864
+ out.push(skill);
1865
+ });
1866
+ });
1867
+ return out;
1868
+ }
1869
+
1870
+ function skillMetaLine(skill) {
1871
+ var bits = [];
1872
+ bits.push(skill.files + (skill.files === 1 ? ' archivo' : ' archivos'));
1873
+ if (skill.version) bits.push('v' + skill.version);
1874
+ if (skill.hasCommand) bits.push('command');
1875
+ return bits.join(' \xB7 ');
1876
+ }
1877
+
1878
+ function renderSkillList() {
1879
+ var container = q('[data-list="skills"]');
1880
+ if (!container) return;
1881
+ container.textContent = '';
1882
+ var report = state.skills;
1883
+ if (!report) {
1884
+ container.appendChild(el('p', { class: 'loading', text: 'Leyendo las skills instaladas\u2026' }));
1885
+ return;
1886
+ }
1887
+ var shown = visibleSkills(report, state.scope);
1888
+ if (!shown.length) {
1889
+ container.appendChild(emptyState({
1890
+ title: state.filter.skills ? 'Ninguna skill coincide con el filtro' : 'No hay skills en este alcance',
1891
+ text: state.filter.skills
1892
+ ? 'Limpi\xE1 el filtro para ver todo lo instalado.'
1893
+ : 'Instalalas con: fwinit skills --global ' + state.scope,
1894
+ hint: String(shown.length) + ' de ' + String(report.totals[state.scope]) + ' en este alcance'
1895
+ }));
1896
+ }
1897
+ (report.targets || []).forEach(function (target) {
1898
+ var skills = (target.skills || []).filter(function (skill) {
1899
+ return skill.scope === state.scope && shown.indexOf(skill) !== -1;
1900
+ });
1901
+ var dir = state.scope === 'global' ? target.globalDir : target.projectDir;
1902
+ var exists = state.scope === 'global' ? target.globalExists : target.projectExists;
1903
+ var collapsed = state.collapsed[state.scope + ':' + target.id] === true;
1904
+ var group = el('div', { class: 'group' });
1905
+
1906
+ group.appendChild(el('button', {
1907
+ type: 'button',
1908
+ class: 'group__head',
1909
+ 'aria-expanded': collapsed ? 'false' : 'true',
1910
+ onclick: function () {
1911
+ state.collapsed[state.scope + ':' + target.id] = !collapsed;
1912
+ renderSkillList();
1913
+ }
1914
+ }, [
1915
+ target.label,
1916
+ el('span', { class: 'group__count', text: String(skills.length) })
1917
+ ]));
1918
+
1919
+ if (!collapsed) {
1920
+ group.appendChild(el('p', { class: 'group__path', title: dir, text: shortPath(dir) }));
1921
+ if (!exists || !skills.length) {
1922
+ group.appendChild(el('p', {
1923
+ class: 'row--empty',
1924
+ text: exists ? 'No hay skills ac\xE1' : 'No instalado'
1925
+ }));
1926
+ }
1927
+ skills.forEach(function (skill) {
1928
+ var key = skill.target + '/' + skill.name;
1929
+ group.appendChild(el('button', {
1930
+ type: 'button',
1931
+ class: 'row',
1932
+ 'aria-current': state.skillKey === key ? 'true' : 'false',
1933
+ title: skill.description || skill.name,
1934
+ onclick: function () { navigate(skillHash(skill.target, skill.name)); }
1935
+ }, [
1936
+ el('span', { class: 'row__name', text: skill.name }),
1937
+ el('span', { class: 'row__meta', text: skillMetaLine(skill) })
1938
+ ]));
1939
+ });
1940
+ }
1941
+ container.appendChild(group);
1942
+ });
1943
+ // Avisos del reporte (directorios ilegibles o symlinks salteados): sin esto
1944
+ // una skill salteada desaparece de la lista sin explicaci\xF3n.
1945
+ (report.warnings || []).forEach(function (warning) {
1946
+ container.appendChild(el('p', { class: 'row--empty', text: 'Aviso: ' + warning }));
1947
+ });
1948
+ }
1949
+
1950
+ function selectedSkill() {
1951
+ if (!state.skills || !state.skillKey) return null;
1952
+ var parts = state.skillKey.split('/');
1953
+ var target = parts[0];
1954
+ var name = parts.slice(1).join('/');
1955
+ var found = null;
1956
+ (state.skills.targets || []).forEach(function (entry) {
1957
+ if (entry.id !== target) return;
1958
+ (entry.skills || []).forEach(function (skill) {
1959
+ if (skill.scope !== state.scope) return;
1960
+ if (skill.name !== name) return;
1961
+ found = skill;
1962
+ });
1963
+ });
1964
+ return found;
1965
+ }
1966
+
1967
+ function metaRow(key, value, strong) {
1968
+ return el('div', { class: 'meta__row' }, [
1969
+ el('span', { class: 'meta__key', text: key }),
1970
+ el('span', { class: 'meta__value' + (strong ? ' meta__value--strong' : ''), text: value === undefined || value === '' ? '\u2014' : String(value) })
1971
+ ]);
1972
+ }
1973
+
1974
+ function renderSkillDetail() {
1975
+ var container = q('[data-detail="skills"]');
1976
+ if (!container) return;
1977
+ container.textContent = '';
1978
+ if (!state.skills) {
1979
+ container.appendChild(el('p', { class: 'loading', text: 'Leyendo las skills instaladas\u2026' }));
1980
+ return;
1981
+ }
1982
+ var skill = selectedSkill();
1983
+ if (!skill) {
1984
+ var target = (state.skills.targets || [])[0] || null;
1985
+ var dir = target ? (state.scope === 'global' ? target.globalDir : target.projectDir) : '';
1986
+ container.appendChild(emptyState({
1987
+ title: 'Eleg\xED una skill',
1988
+ text: 'Las skills de este alcance est\xE1n a la izquierda, agrupadas por la herramienta que las lee.',
1989
+ path: dir ? shortPath(dir) : undefined,
1990
+ hint: 'fwinit skills --global opencode|pi|gentle-shell'
1991
+ }));
1992
+ return;
1993
+ }
1994
+ var report = null;
1995
+ (state.skills.targets || []).forEach(function (entry) {
1996
+ if (entry.id === skill.target) report = entry;
1997
+ });
1998
+
1999
+ container.appendChild(el('header', null, [
2000
+ el('p', { class: 'eyebrow', text: (report ? report.label : skill.target) + ' \xB7 ' + skill.scope }),
2001
+ el('h1', { class: 'detail__title', text: skill.name }),
2002
+ el('p', { class: 'detail__lede', text: skill.description || 'Esta skill no tiene descripci\xF3n en su frontmatter.' }),
2003
+ el('div', { class: 'detail__actions' }, [
2004
+ copyButton('Copiar ruta', skill.path),
2005
+ copyButton('Copiar nombre', skill.name),
2006
+ skill.version ? el('span', { class: 'badge badge--accent', text: 'v' + skill.version }) : null,
2007
+ el('span', { class: 'badge badge--muted', text: skill.files + (skill.files === 1 ? ' archivo' : ' archivos') }),
2008
+ skill.hasCommand ? el('span', { class: 'badge badge--muted', text: 'command.md' }) : null
2009
+ ])
2010
+ ]));
2011
+
2012
+ container.appendChild(el('div', { class: 'meta' }, [
2013
+ metaRow('Directorio', shortPath(skill.dir), true),
2014
+ metaRow('Archivo', skill.path.replace(meta.projectRoot || '', '')),
2015
+ metaRow('Destino', report ? report.label : skill.target),
2016
+ metaRow('Alcance', skill.scope === 'global' ? 'instalaci\xF3n global del usuario' : 'este proyecto'),
2017
+ metaRow('Versi\xF3n', skill.version),
2018
+ metaRow('Licencia', skill.license),
2019
+ metaRow('Autor', skill.author),
2020
+ metaRow('Archivos', String(skill.files)),
2021
+ metaRow('Tama\xF1o', fmtBytes(skill.bytes)),
2022
+ metaRow('Modificado', fmtDate(skill.modified))
2023
+ ]));
2024
+
2025
+ if (skill.warnings && skill.warnings.length) {
2026
+ container.appendChild(el('div', { class: 'meta' }, skill.warnings.map(function (warning) {
2027
+ return el('div', { class: 'meta__row' }, [
2028
+ el('span', { class: 'meta__key', text: 'Aviso' }),
2029
+ el('span', { class: 'meta__value', text: warning })
2030
+ ]);
2031
+ })));
2032
+ }
2033
+ }
2034
+
2035
+ /* \u2500\u2500 Specs view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2036
+
2037
+ function matchesFilter(node, filter) {
2038
+ if (!filter) return true;
2039
+ if (node.path.toLowerCase().indexOf(filter) !== -1) return true;
2040
+ return (node.children || []).some(function (child) { return matchesFilter(child, filter); });
2041
+ }
2042
+
2043
+ function renderTreeNodes(nodes, depth, container) {
2044
+ var filter = state.filter.specs.trim().toLowerCase();
2045
+ (nodes || []).forEach(function (node) {
2046
+ if (!matchesFilter(node, filter)) return;
2047
+ if (node.type === 'dir') {
2048
+ var key = node.path;
2049
+ var open = filter ? true : (state.expanded[key] === true || (state.expanded[key] !== false && depth < 2));
2050
+ container.appendChild(el('button', {
2051
+ type: 'button',
2052
+ class: 'row row--dir',
2053
+ 'aria-expanded': open ? 'true' : 'false',
2054
+ onclick: function () {
2055
+ state.expanded[key] = !open;
2056
+ renderSpecTree();
2057
+ }
2058
+ }, [
2059
+ el('span', { class: 'tree__toggle' }, [
2060
+ el('span', { class: 'tree__chevron', dataset: { open: open ? 'true' : 'false' }, text: '\u203A' }),
2061
+ node.name
2062
+ ]),
2063
+ el('span', { class: 'row__meta', text: String(countFiles(node)) + ' arch.' })
2064
+ ]));
2065
+ if (open) {
2066
+ var kids = el('div', { class: 'tree__children' });
2067
+ renderTreeNodes(node.children, depth + 1, kids);
2068
+ container.appendChild(kids);
2069
+ }
2070
+ return;
2071
+ }
2072
+ container.appendChild(el('button', {
2073
+ type: 'button',
2074
+ class: 'row row--file',
2075
+ 'aria-current': state.specPath === node.path ? 'true' : 'false',
2076
+ title: node.path,
2077
+ onclick: function () { navigate(specHash(node.path)); }
2078
+ }, [
2079
+ el('span', { class: 'row__name', text: node.name }),
2080
+ el('span', { class: 'row__meta', text: fmtBytes(node.bytes) })
2081
+ ]));
2082
+ });
2083
+ }
2084
+
2085
+ function countFiles(node) {
2086
+ if (node.type === 'file') return 1;
2087
+ var total = 0;
2088
+ (node.children || []).forEach(function (child) { total += countFiles(child); });
2089
+ return total;
2090
+ }
2091
+
2092
+ function renderSpecTree() {
2093
+ var container = q('[data-list="specs"]');
2094
+ if (!container) return;
2095
+ container.textContent = '';
2096
+ var specs = state.specs;
2097
+ if (!specs) {
2098
+ container.appendChild(el('p', { class: 'loading', text: 'Buscando specs/\u2026' }));
2099
+ return;
2100
+ }
2101
+ if (!specs.found) {
2102
+ container.appendChild(el('p', { class: 'row--empty', text: 'Este proyecto no tiene carpeta specs/' }));
2103
+ return;
2104
+ }
2105
+ if (!specs.tree.length) {
2106
+ container.appendChild(el('p', { class: 'row--empty', text: 'specs/ est\xE1 vac\xEDa' }));
2107
+ return;
2108
+ }
2109
+ var tree = el('div', { class: 'tree' });
2110
+ renderTreeNodes(specs.tree, 0, tree);
2111
+ container.appendChild(tree);
2112
+ }
2113
+
2114
+ var SANITIZE_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'form', 'base', 'noscript'];
2115
+
2116
+ function sanitize(html) {
2117
+ var doc = new DOMParser().parseFromString('<div id="studio-root">' + html + '</div>', 'text/html');
2118
+ var root = doc.getElementById('studio-root');
2119
+ if (!root) return '';
2120
+ qa(SANITIZE_TAGS.join(','), root).forEach(function (node) { node.remove(); });
2121
+ qa('*', root).forEach(function (node) {
2122
+ Array.prototype.slice.call(node.attributes).forEach(function (attribute) {
2123
+ var name = attribute.name.toLowerCase();
2124
+ if (name.indexOf('on') === 0 || name === 'style' || name === 'srcdoc') {
2125
+ node.removeAttribute(attribute.name);
2126
+ return;
2127
+ }
2128
+ if (name === 'href' || name === 'src' || name === 'xlink:href') {
2129
+ var value = String(attribute.value).trim().toLowerCase();
2130
+ var safe = value.indexOf('http://') === 0 || value.indexOf('https://') === 0 ||
2131
+ value.indexOf('mailto:') === 0 || value.indexOf('#') === 0;
2132
+ if (!safe) node.removeAttribute(attribute.name);
2133
+ }
2134
+ });
2135
+ });
2136
+ return root.innerHTML;
2137
+ }
2138
+
2139
+ function renderToc(headings) {
2140
+ var list = q('[data-toc-list]');
2141
+ if (!list) return;
2142
+ list.textContent = '';
2143
+ state.tocLinks = [];
2144
+ var items = (headings || []).filter(function (heading) { return heading.level >= 2 && heading.level <= 3; });
2145
+ if (!items.length) {
2146
+ list.appendChild(el('p', { class: 'empty__hint', text: 'Sin secciones' }));
2147
+ return;
2148
+ }
2149
+ items.forEach(function (heading) {
2150
+ var link = el('a', {
2151
+ class: 'toc__link',
2152
+ href: '#' + heading.id,
2153
+ dataset: { level: String(heading.level), target: heading.id },
2154
+ text: heading.text,
2155
+ onclick: function (event) {
2156
+ event.preventDefault();
2157
+ var target = document.getElementById(heading.id);
2158
+ if (target) target.scrollIntoView({ block: 'start', behavior: 'smooth' });
2159
+ }
2160
+ });
2161
+ list.appendChild(link);
2162
+ state.tocLinks.push(link);
2163
+ });
2164
+ }
2165
+
2166
+ function renderSpecDoc() {
2167
+ var container = q('[data-detail="specs"]');
2168
+ if (!container) return;
2169
+ var specs = state.specs;
2170
+ if (!specs) {
2171
+ container.textContent = '';
2172
+ container.appendChild(el('p', { class: 'loading', text: 'Buscando specs/\u2026' }));
2173
+ return;
2174
+ }
2175
+ if (!specs.found) {
2176
+ container.textContent = '';
2177
+ container.appendChild(emptyState({
2178
+ title: 'Este proyecto todav\xEDa no tiene specs/',
2179
+ text: 'El studio busca specs/ en la ra\xEDz del proyecto. Creala con la skill create-specs y recarg\xE1 la p\xE1gina.',
2180
+ path: specs.projectRoot + '/specs',
2181
+ hint: 'Skill: create-specs \xB7 /create-specs'
2182
+ }));
2183
+ renderToc([]);
2184
+ return;
2185
+ }
2186
+ var doc = state.doc;
2187
+ if (!doc) {
2188
+ container.textContent = '';
2189
+ container.appendChild(
2190
+ state.specPath ? el('p', { class: 'loading', text: 'Leyendo la spec\u2026' }) : emptyState({
2191
+ title: 'Eleg\xED un documento',
2192
+ text: 'El \xE1rbol de la izquierda es el specs/ de este proyecto, ordenado como lo escribe la skill create-specs.',
2193
+ path: specs.root ? shortPath(specs.root) : undefined,
2194
+ hint: specs.totals.files + ' archivos \xB7 ' + fmtBytes(specs.totals.bytes)
2195
+ })
2196
+ );
2197
+ renderToc([]);
2198
+ return;
2199
+ }
2200
+ container.textContent = '';
2201
+ container.appendChild(el('header', { class: 'doc-head' }, [
2202
+ el('p', { class: 'doc-head__path', text: 'specs/' + doc.path }),
2203
+ el('h1', { class: 'doc-head__title', text: doc.title }),
2204
+ el('div', { class: 'doc-head__stats' }, [
2205
+ el('span', { text: fmtBytes(doc.bytes) }),
2206
+ el('span', { text: 'modificado ' + fmtDate(doc.modified) }),
2207
+ el('span', { text: (doc.headings ? doc.headings.length : 0) + ' t\xEDtulos' }),
2208
+ copyButton('Copiar ruta', doc.absolutePath)
2209
+ ])
2210
+ ]));
2211
+ var article = el('article', { class: 'doc' });
2212
+ article.innerHTML = sanitize(doc.html || '');
2213
+ // El t\xEDtulo ya vive en la cabecera del documento: repetirlo en el cuerpo
2214
+ // deja dos h1 iguales en la misma p\xE1gina.
2215
+ var duplicateTitle = q('h1', article);
2216
+ if (duplicateTitle && duplicateTitle.textContent.trim() === doc.title.trim()) {
2217
+ duplicateTitle.remove();
2218
+ }
2219
+ qa('[data-spec-link]', article).forEach(function (link) {
2220
+ link.addEventListener('click', function (event) {
2221
+ event.preventDefault();
2222
+ var target = link.getAttribute('data-spec-link');
2223
+ if (!target) return;
2224
+ // El server ya reescribi\xF3 el target como path desde specs/: resolverlo
2225
+ // otra vez contra doc.path duplicar\xEDa el prefijo (modules/x \u2192 docs/modules/x).
2226
+ navigate(specHash(target));
2227
+ });
2228
+ });
2229
+ container.appendChild(article);
2230
+ if (doc.warnings && doc.warnings.length) {
2231
+ container.appendChild(el('div', { class: 'meta' }, doc.warnings.map(function (warning) {
2232
+ return el('div', { class: 'meta__row' }, [
2233
+ el('span', { class: 'meta__key', text: 'Aviso' }),
2234
+ el('span', { class: 'meta__value', text: warning })
2235
+ ]);
2236
+ })));
2237
+ }
2238
+ renderToc(doc.headings);
2239
+ container.scrollTop = 0;
2240
+ }
2241
+
2242
+ function loadSpecDoc() {
2243
+ if (!state.specPath) {
2244
+ state.doc = null;
2245
+ renderSpecDoc();
2246
+ return;
2247
+ }
2248
+ var path = state.specPath;
2249
+ q('[data-list="specs"]');
2250
+ fetchJson('/api/specs/file?path=' + encodeURIComponent(path)).then(function (doc) {
2251
+ if (state.specPath !== path) return;
2252
+ state.doc = doc;
2253
+ renderSpecDoc();
2254
+ renderSpecTree();
2255
+ }, function (error) {
2256
+ if (state.specPath !== path) return;
2257
+ state.doc = null;
2258
+ renderSpecDoc();
2259
+ var container = q('[data-detail="specs"]');
2260
+ if (container) {
2261
+ container.textContent = '';
2262
+ renderError(container, error);
2263
+ }
2264
+ });
2265
+ }
2266
+
2267
+ /* \u2500\u2500 Render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2268
+
2269
+ function render() {
2270
+ syncShell();
2271
+ if (state.view === 'skills') {
2272
+ renderSkillList();
2273
+ renderSkillDetail();
2274
+ return;
2275
+ }
2276
+ renderSpecTree();
2277
+ renderSpecDoc();
2278
+ }
2279
+
2280
+ function bootSkills() {
2281
+ loadSkills().then(function () {
2282
+ // Elegir la primera skill no debe reescribir la URL: si el usuario lleg\xF3
2283
+ // por un deep link a /specs, el hash ya es la ruta que eligi\xF3.
2284
+ if (!state.skillKey) {
2285
+ var first = visibleSkills(state.skills, state.scope)[0];
2286
+ if (first) state.skillKey = first.target + '/' + first.name;
2287
+ }
2288
+ renderSkillList();
2289
+ renderSkillDetail();
2290
+ syncShell();
2291
+ }, function (error) {
2292
+ var list = q('[data-list="skills"]');
2293
+ var detail = q('[data-detail="skills"]');
2294
+ if (list) { list.textContent = ''; renderError(list, error); }
2295
+ if (detail) { detail.textContent = ''; renderError(detail, error); }
2296
+ });
2297
+ }
2298
+
2299
+ function bootSpecs() {
2300
+ loadSpecs().then(function (specs) {
2301
+ renderSpecTree();
2302
+ syncShell();
2303
+ if (!state.specPath && specs.found) {
2304
+ var firstFile = null;
2305
+ var walk = function (nodes) {
2306
+ (nodes || []).forEach(function (node) {
2307
+ if (firstFile) return;
2308
+ if (node.type === 'file') { firstFile = node; return; }
2309
+ walk(node.children);
2310
+ });
2311
+ };
2312
+ walk(specs.tree);
2313
+ if (firstFile) state.specPath = firstFile.path;
2314
+ }
2315
+ loadSpecDoc();
2316
+ }, function (error) {
2317
+ var list = q('[data-list="specs"]');
2318
+ var detail = q('[data-detail="specs"]');
2319
+ if (list) { list.textContent = ''; renderError(list, error); }
2320
+ if (detail) { detail.textContent = ''; renderError(detail, error); }
2321
+ });
2322
+ }
2323
+
2324
+ function boot() {
2325
+ applyTheme(currentTheme());
2326
+
2327
+ var toggle = q('[data-theme-toggle]');
2328
+ if (toggle) toggle.addEventListener('click', toggleTheme);
2329
+
2330
+ qa('[data-view]').forEach(function (tab) {
2331
+ tab.addEventListener('click', function () { navigate('#/' + tab.dataset.view); });
2332
+ });
2333
+
2334
+ qa('[data-scope]').forEach(function (button) {
2335
+ button.addEventListener('click', function () {
2336
+ state.scope = button.dataset.scope;
2337
+ state.skillKey = null;
2338
+ qa('[data-scope]').forEach(function (other) {
2339
+ other.setAttribute('aria-pressed', other.dataset.scope === state.scope ? 'true' : 'false');
2340
+ });
2341
+ var first = state.skills ? visibleSkills(state.skills, state.scope)[0] : null;
2342
+ if (first) {
2343
+ state.skillKey = first.target + '/' + first.name;
2344
+ history.replaceState(null, '', skillHash(first.target, first.name));
2345
+ }
2346
+ renderSkillList();
2347
+ renderSkillDetail();
2348
+ syncShell();
2349
+ });
2350
+ });
2351
+
2352
+ qa('[data-search]').forEach(function (input) {
2353
+ input.addEventListener('input', function () {
2354
+ state.filter[input.dataset.search] = input.value;
2355
+ if (input.dataset.search === 'skills') {
2356
+ renderSkillList();
2357
+ return;
2358
+ }
2359
+ renderSpecTree();
2360
+ });
2361
+ });
2362
+
2363
+ document.addEventListener('keydown', function (event) {
2364
+ var target = event.target;
2365
+ var typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
2366
+ if (event.key === '/' && !typing) {
2367
+ event.preventDefault();
2368
+ var input = q('[data-search="' + state.view + '"]');
2369
+ if (input) input.focus();
2370
+ return;
2371
+ }
2372
+ if (event.key === 'Escape' && typing) {
2373
+ target.value = '';
2374
+ state.filter[state.view] = '';
2375
+ render();
2376
+ target.blur();
2377
+ return;
2378
+ }
2379
+ if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
2380
+ if (event.key === 't') { toggleTheme(); return; }
2381
+ if (event.key === '1') { navigate('#/skills'); return; }
2382
+ if (event.key === '2') { navigate('#/specs'); return; }
2383
+ });
2384
+
2385
+ var pane = q('[data-detail="specs"]');
2386
+ if (pane) {
2387
+ pane.addEventListener('scroll', function () {
2388
+ if (!state.tocLinks.length) return;
2389
+ var active = null;
2390
+ for (var i = 0; i < state.tocLinks.length; i++) {
2391
+ var heading = document.getElementById(state.tocLinks[i].dataset.target);
2392
+ if (!heading) continue;
2393
+ if (heading.getBoundingClientRect().top - pane.getBoundingClientRect().top <= 24) active = state.tocLinks[i];
2394
+ }
2395
+ state.tocLinks.forEach(function (link) {
2396
+ link.setAttribute('aria-current', link === active ? 'true' : 'false');
720
2397
  });
2398
+ });
2399
+ }
2400
+
2401
+ window.addEventListener('hashchange', route);
2402
+
2403
+ route();
2404
+ bootSkills();
2405
+ bootSpecs();
2406
+ }
2407
+
2408
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
2409
+ else boot();
2410
+ })();
2411
+ `;
2412
+
2413
+ // src/studio/ui/styles.ts
2414
+ var STUDIO_CSS = `
2415
+ *,
2416
+ *::before,
2417
+ *::after {
2418
+ box-sizing: border-box;
2419
+ }
2420
+
2421
+ * {
2422
+ margin: 0;
2423
+ }
2424
+
2425
+ html {
2426
+ -webkit-text-size-adjust: 100%;
2427
+ }
2428
+
2429
+ body {
2430
+ min-height: 100vh;
2431
+ background-color: var(--base);
2432
+ color: var(--text);
2433
+ font-family: var(--font-sans);
2434
+ font-size: var(--text-small);
2435
+ line-height: var(--leading-body);
2436
+ font-synthesis-weight: none;
2437
+ -webkit-font-smoothing: antialiased;
2438
+ -moz-osx-font-smoothing: grayscale;
2439
+ transition:
2440
+ background-color var(--duration-base) ease,
2441
+ color var(--duration-base) ease;
2442
+ }
2443
+
2444
+ svg {
2445
+ display: block;
2446
+ flex: none;
2447
+ }
2448
+
2449
+ a {
2450
+ color: var(--accent);
2451
+ text-decoration: underline;
2452
+ text-decoration-thickness: 1px;
2453
+ text-underline-offset: 0.2em;
2454
+ transition: color var(--duration-fast) ease;
2455
+ }
2456
+
2457
+ a:hover {
2458
+ color: var(--accent-hover);
2459
+ }
2460
+
2461
+ a:not(:hover) {
2462
+ text-decoration-color: var(--accent-border);
2463
+ }
2464
+
2465
+ button,
2466
+ input {
2467
+ font: inherit;
2468
+ color: inherit;
2469
+ }
2470
+
2471
+ button {
2472
+ background: none;
2473
+ border: 0;
2474
+ cursor: pointer;
2475
+ }
2476
+
2477
+ :focus-visible {
2478
+ outline: 2px solid var(--focus-ring);
2479
+ outline-offset: 2px;
2480
+ }
2481
+
2482
+ ::selection {
2483
+ background: var(--accent-soft);
2484
+ color: var(--text);
2485
+ }
2486
+
2487
+ .skip {
2488
+ position: absolute;
2489
+ left: -9999px;
2490
+ top: 0;
2491
+ z-index: 60;
2492
+ background: var(--surface-elevated);
2493
+ border: 1px solid var(--accent-border);
2494
+ padding: var(--space-2xs) var(--space-xs);
2495
+ font-family: var(--font-mono);
2496
+ font-size: var(--text-caption);
2497
+ }
2498
+
2499
+ .skip:focus {
2500
+ left: var(--space-sm);
2501
+ top: var(--space-2xs);
2502
+ }
2503
+
2504
+ /* \u2500\u2500 Top bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2505
+ .topbar {
2506
+ position: fixed;
2507
+ inset: 0 0 auto 0;
2508
+ z-index: 50;
2509
+ height: var(--header-height);
2510
+ background: var(--base);
2511
+ border-bottom: var(--hairline-strong);
2512
+ }
2513
+
2514
+ .topbar__inner {
2515
+ display: grid;
2516
+ grid-template-columns: auto 1fr auto;
2517
+ align-items: center;
2518
+ gap: var(--space-md);
2519
+ height: 100%;
2520
+ max-width: var(--container);
2521
+ margin-inline: auto;
2522
+ padding-inline: var(--container-pad);
2523
+ }
2524
+
2525
+ .brand {
2526
+ display: inline-flex;
2527
+ align-items: center;
2528
+ gap: var(--space-2xs);
2529
+ color: var(--text);
2530
+ text-decoration: none;
2531
+ font-family: var(--font-mono);
2532
+ font-size: var(--text-small);
2533
+ font-weight: 600;
2534
+ letter-spacing: -0.01em;
2535
+ }
2536
+
2537
+ .brand:hover {
2538
+ color: var(--text);
2539
+ }
2540
+
2541
+ .brand__glyph {
2542
+ display: grid;
2543
+ place-items: center;
2544
+ width: 28px;
2545
+ height: 28px;
2546
+ background: var(--accent-soft);
2547
+ border: 1px solid var(--accent-border);
2548
+ color: var(--accent);
2549
+ font-size: var(--text-caption);
2550
+ font-weight: 700;
2551
+ }
2552
+
2553
+ .brand__div {
2554
+ color: var(--text-muted);
2555
+ font-weight: 400;
2556
+ }
2557
+
2558
+ .brand__page {
2559
+ color: var(--accent);
2560
+ font-weight: 500;
2561
+ }
2562
+
2563
+ .tabs {
2564
+ display: flex;
2565
+ align-items: center;
2566
+ justify-content: center;
2567
+ gap: var(--space-md);
2568
+ height: 100%;
2569
+ }
2570
+
2571
+ .tab {
2572
+ position: relative;
2573
+ display: inline-flex;
2574
+ align-items: center;
2575
+ gap: var(--space-2xs);
2576
+ height: 100%;
2577
+ color: var(--text-secondary);
2578
+ font-size: var(--text-small);
2579
+ font-weight: 500;
2580
+ transition: color var(--duration-fast) ease;
2581
+ }
2582
+
2583
+ .tab::after {
2584
+ content: '';
2585
+ position: absolute;
2586
+ inset: auto 0 0 0;
2587
+ height: 2px;
2588
+ background: var(--accent);
2589
+ transform: scaleX(0);
2590
+ transform-origin: left;
2591
+ transition: transform var(--duration-base) var(--ease-out);
2592
+ }
2593
+
2594
+ .tab:hover {
2595
+ color: var(--text);
2596
+ }
2597
+
2598
+ .tab[aria-selected='true'] {
2599
+ color: var(--text);
2600
+ }
2601
+
2602
+ .tab[aria-selected='true']::after {
2603
+ transform: scaleX(1);
2604
+ }
2605
+
2606
+ .tab__count {
2607
+ font-family: var(--font-mono);
2608
+ font-size: var(--text-caption);
2609
+ color: var(--text-muted);
2610
+ }
2611
+
2612
+ .tab[aria-selected='true'] .tab__count {
2613
+ color: var(--accent);
2614
+ }
2615
+
2616
+ .topbar__actions {
2617
+ display: flex;
2618
+ align-items: center;
2619
+ gap: var(--space-2xs);
2620
+ }
2621
+
2622
+ .badge {
2623
+ display: inline-flex;
2624
+ align-items: center;
2625
+ gap: var(--space-3xs);
2626
+ padding: 0.125rem var(--space-2xs);
2627
+ font-family: var(--font-mono);
2628
+ font-size: var(--text-caption);
2629
+ line-height: 1.5;
2630
+ white-space: nowrap;
2631
+ }
2632
+
2633
+ .badge--version {
2634
+ color: var(--text-secondary);
2635
+ background: var(--surface);
2636
+ border: 1px solid var(--border);
2637
+ }
2638
+
2639
+ .badge--accent {
2640
+ color: var(--accent);
2641
+ background: var(--accent-soft);
2642
+ border: 1px solid var(--accent-border);
2643
+ }
2644
+
2645
+ .badge--muted {
2646
+ color: var(--text-muted);
2647
+ background: var(--surface);
2648
+ border: 1px solid var(--border);
2649
+ }
2650
+
2651
+ .btn {
2652
+ display: inline-flex;
2653
+ align-items: center;
2654
+ gap: var(--space-2xs);
2655
+ padding: var(--space-3xs) var(--space-2xs);
2656
+ color: var(--text-secondary);
2657
+ border: 1px solid var(--border);
2658
+ background: var(--surface);
2659
+ font-size: var(--text-caption);
2660
+ transition:
2661
+ color var(--duration-fast) ease,
2662
+ border-color var(--duration-fast) ease,
2663
+ background-color var(--duration-fast) ease;
2664
+ }
2665
+
2666
+ .btn:hover {
2667
+ color: var(--text);
2668
+ border-color: var(--accent-border);
2669
+ background: var(--surface-elevated);
2670
+ }
2671
+
2672
+ .btn--icon {
2673
+ width: 34px;
2674
+ height: 34px;
2675
+ padding: 0;
2676
+ justify-content: center;
2677
+ }
2678
+
2679
+ .btn[data-copied='true'] {
2680
+ color: var(--success);
2681
+ border-color: var(--success);
2682
+ }
2683
+
2684
+ .theme-toggle .icon-sun {
2685
+ display: none;
2686
+ }
2687
+
2688
+ [data-theme='light'] .theme-toggle .icon-sun {
2689
+ display: block;
2690
+ }
2691
+
2692
+ [data-theme='light'] .theme-toggle .icon-moon {
2693
+ display: none;
2694
+ }
2695
+
2696
+ /* \u2500\u2500 Shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2697
+ .shell {
2698
+ padding-top: var(--header-height);
2699
+ padding-bottom: 36px;
2700
+ padding-inline: var(--container-pad);
2701
+ height: 100vh;
2702
+ max-width: var(--container);
2703
+ margin-inline: auto;
2704
+ }
2705
+
2706
+ .view {
2707
+ display: none;
2708
+ height: 100%;
2709
+ border-inline: var(--hairline);
2710
+ }
2711
+
2712
+ .view[data-active='true'] {
2713
+ display: grid;
2714
+ }
2715
+
2716
+ .view--skills {
2717
+ grid-template-columns: 340px minmax(0, 1fr);
2718
+ }
2719
+
2720
+ .view--specs {
2721
+ grid-template-columns: 320px minmax(0, 1fr) 232px;
2722
+ }
2723
+
2724
+ .rail {
2725
+ display: flex;
2726
+ flex-direction: column;
2727
+ min-height: 0;
2728
+ border-right: var(--hairline-strong);
2729
+ background: var(--surface);
2730
+ }
2731
+
2732
+ .rail__head {
2733
+ display: grid;
2734
+ gap: var(--space-2xs);
2735
+ padding: var(--space-sm);
2736
+ border-bottom: var(--hairline-strong);
2737
+ }
2738
+
2739
+ .rail__body {
2740
+ overflow-y: auto;
2741
+ padding: var(--space-2xs) 0 var(--space-lg);
2742
+ scrollbar-width: thin;
2743
+ scrollbar-color: var(--border-strong) transparent;
2744
+ }
2745
+
2746
+ .rail__body::-webkit-scrollbar {
2747
+ width: 10px;
2748
+ }
2749
+
2750
+ .rail__body::-webkit-scrollbar-thumb {
2751
+ background: var(--border-strong);
2752
+ border: 3px solid var(--surface);
2753
+ }
2754
+
2755
+ .field {
2756
+ display: flex;
2757
+ align-items: center;
2758
+ gap: var(--space-2xs);
2759
+ padding: 0.375rem var(--space-2xs);
2760
+ background: var(--base);
2761
+ border: 1px solid var(--border);
2762
+ color: var(--text-muted);
2763
+ transition: border-color var(--duration-fast) ease;
2764
+ }
2765
+
2766
+ .field:focus-within {
2767
+ border-color: var(--border-input);
2768
+ }
2769
+
2770
+ .field__input {
2771
+ flex: 1;
2772
+ min-width: 0;
2773
+ border: 0;
2774
+ outline: none;
2775
+ background: none;
2776
+ color: var(--text);
2777
+ font-size: var(--text-small);
2778
+ }
2779
+
2780
+ .field__input::placeholder {
2781
+ color: var(--text-muted);
2782
+ }
2783
+
2784
+ .field kbd {
2785
+ flex: none;
2786
+ padding: 0 0.25rem;
2787
+ border: 1px solid var(--border);
2788
+ color: var(--text-muted);
2789
+ font-family: var(--font-mono);
2790
+ font-size: var(--text-caption);
2791
+ line-height: 1.4;
2792
+ }
2793
+
2794
+ .segmented {
2795
+ display: grid;
2796
+ grid-template-columns: 1fr 1fr;
2797
+ border: 1px solid var(--border);
2798
+ }
2799
+
2800
+ .segmented button {
2801
+ padding: 0.25rem var(--space-2xs);
2802
+ color: var(--text-secondary);
2803
+ background: var(--base);
2804
+ font-family: var(--font-mono);
2805
+ font-size: var(--text-caption);
2806
+ letter-spacing: 0.02em;
2807
+ transition:
2808
+ color var(--duration-fast) ease,
2809
+ background-color var(--duration-fast) ease;
2810
+ }
2811
+
2812
+ .segmented button + button {
2813
+ border-left: 1px solid var(--border);
2814
+ }
2815
+
2816
+ .segmented button:hover {
2817
+ color: var(--text);
2818
+ }
2819
+
2820
+ .segmented button[aria-pressed='true'] {
2821
+ color: var(--accent);
2822
+ background: var(--accent-soft);
2823
+ }
2824
+
2825
+ /* \u2500\u2500 Rail groups and rows \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2826
+ .group {
2827
+ margin-top: var(--space-2xs);
2828
+ }
2829
+
2830
+ .group__head {
2831
+ display: flex;
2832
+ align-items: baseline;
2833
+ gap: var(--space-2xs);
2834
+ width: 100%;
2835
+ padding: var(--space-3xs) var(--space-sm);
2836
+ color: var(--text-muted);
2837
+ font-family: var(--font-mono);
2838
+ font-size: var(--text-caption);
2839
+ letter-spacing: 0.04em;
2840
+ text-transform: uppercase;
2841
+ text-align: left;
2842
+ }
2843
+
2844
+ .group__head:hover {
2845
+ color: var(--text-secondary);
2846
+ }
2847
+
2848
+ .group__count {
2849
+ margin-left: auto;
2850
+ color: var(--text-muted);
2851
+ letter-spacing: 0;
2852
+ }
2853
+
2854
+ .group__path {
2855
+ padding: 0 var(--space-sm) var(--space-3xs);
2856
+ color: var(--text-muted);
2857
+ font-family: var(--font-mono);
2858
+ font-size: var(--text-caption);
2859
+ overflow: hidden;
2860
+ text-overflow: ellipsis;
2861
+ white-space: nowrap;
2862
+ text-align: left;
2863
+ }
2864
+
2865
+ .row {
2866
+ display: grid;
2867
+ gap: 0.125rem;
2868
+ width: 100%;
2869
+ padding: var(--space-3xs) var(--space-sm);
2870
+ border-left: 2px solid transparent;
2871
+ color: var(--text-secondary);
2872
+ text-align: left;
2873
+ transition:
2874
+ color var(--duration-fast) ease,
2875
+ background-color var(--duration-fast) ease,
2876
+ border-color var(--duration-fast) ease;
2877
+ }
2878
+
2879
+ .row:hover {
2880
+ color: var(--text);
2881
+ background: var(--surface-elevated);
2882
+ }
2883
+
2884
+ .row[aria-current='true'] {
2885
+ color: var(--text);
2886
+ background: var(--accent-soft);
2887
+ border-left-color: var(--accent);
2888
+ }
2889
+
2890
+ .row__name {
2891
+ font-family: var(--font-mono);
2892
+ font-size: var(--text-caption);
2893
+ overflow: hidden;
2894
+ text-overflow: ellipsis;
2895
+ white-space: nowrap;
2896
+ }
2897
+
2898
+ .row__meta {
2899
+ color: var(--text-muted);
2900
+ font-family: var(--font-mono);
2901
+ font-size: var(--text-caption);
2902
+ overflow: hidden;
2903
+ text-overflow: ellipsis;
2904
+ white-space: nowrap;
2905
+ }
2906
+
2907
+ .row--dir .row__name {
2908
+ color: var(--text-secondary);
2909
+ }
2910
+
2911
+ .row--file[aria-current='true'] .row__name {
2912
+ color: var(--accent);
2913
+ }
2914
+
2915
+ .row--empty {
2916
+ padding: var(--space-2xs) var(--space-sm);
2917
+ color: var(--text-muted);
2918
+ font-family: var(--font-mono);
2919
+ font-size: var(--text-caption);
2920
+ border-left: 2px dashed var(--border);
2921
+ margin-left: var(--space-sm);
2922
+ }
2923
+
2924
+ .tree {
2925
+ display: grid;
2926
+ }
2927
+
2928
+ .tree__children {
2929
+ padding-left: var(--space-xs);
2930
+ border-left: var(--hairline);
2931
+ margin-left: var(--space-sm);
2932
+ }
2933
+
2934
+ .tree__toggle {
2935
+ display: inline-flex;
2936
+ align-items: center;
2937
+ gap: var(--space-3xs);
2938
+ }
2939
+
2940
+ .tree__chevron {
2941
+ transition: transform var(--duration-fast) ease;
2942
+ color: var(--text-muted);
2943
+ }
2944
+
2945
+ .tree__chevron[data-open='true'] {
2946
+ transform: rotate(90deg);
2947
+ }
2948
+
2949
+ /* \u2500\u2500 Panes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2950
+ .pane {
2951
+ min-width: 0;
2952
+ overflow-y: auto;
2953
+ padding: var(--space-lg) var(--space-xl) var(--space-2xl);
2954
+ scrollbar-width: thin;
2955
+ scrollbar-color: var(--border-strong) transparent;
2956
+ }
2957
+
2958
+ .pane::-webkit-scrollbar {
2959
+ width: 10px;
2960
+ }
2961
+
2962
+ .pane::-webkit-scrollbar-thumb {
2963
+ background: var(--border-strong);
2964
+ border: 3px solid var(--base);
2965
+ }
2966
+
2967
+ .eyebrow {
2968
+ display: inline-flex;
2969
+ align-items: center;
2970
+ gap: var(--space-3xs);
2971
+ margin-bottom: var(--space-2xs);
2972
+ padding: 0.125rem var(--space-2xs);
2973
+ background: var(--accent-soft);
2974
+ border: 1px solid var(--accent-border);
2975
+ color: var(--accent);
2976
+ font-family: var(--font-mono);
2977
+ font-size: var(--text-caption);
2978
+ letter-spacing: 0.04em;
2979
+ text-transform: uppercase;
2980
+ }
2981
+
2982
+ .detail__title {
2983
+ font-family: var(--font-mono);
2984
+ font-size: clamp(1.5rem, 2.6vw, var(--text-h2));
2985
+ font-weight: 600;
2986
+ line-height: var(--leading-h2);
2987
+ letter-spacing: -0.02em;
2988
+ overflow-wrap: anywhere;
2989
+ }
2990
+
2991
+ .detail__lede {
2992
+ max-width: 68ch;
2993
+ margin-top: var(--space-2xs);
2994
+ color: var(--text-secondary);
2995
+ font-size: var(--text-body);
2996
+ }
2997
+
2998
+ .detail__actions {
2999
+ display: flex;
3000
+ flex-wrap: wrap;
3001
+ gap: var(--space-2xs);
3002
+ margin-top: var(--space-sm);
3003
+ }
3004
+
3005
+ .meta {
3006
+ display: grid;
3007
+ margin-top: var(--space-lg);
3008
+ border-top: var(--hairline-strong);
3009
+ }
3010
+
3011
+ .meta__row {
3012
+ display: grid;
3013
+ grid-template-columns: 132px minmax(0, 1fr);
3014
+ gap: var(--space-sm);
3015
+ align-items: baseline;
3016
+ padding: var(--space-2xs) 0;
3017
+ border-bottom: var(--hairline);
3018
+ }
3019
+
3020
+ .meta__key {
3021
+ color: var(--text-muted);
3022
+ font-family: var(--font-mono);
3023
+ font-size: var(--text-caption);
3024
+ letter-spacing: 0.04em;
3025
+ text-transform: uppercase;
3026
+ }
3027
+
3028
+ .meta__value {
3029
+ min-width: 0;
3030
+ color: var(--text-secondary);
3031
+ font-family: var(--font-mono);
3032
+ font-size: var(--text-caption);
3033
+ overflow-wrap: anywhere;
3034
+ }
3035
+
3036
+ .meta__value--strong {
3037
+ color: var(--text);
3038
+ }
3039
+
3040
+ /* \u2500\u2500 Document \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3041
+ .doc-head {
3042
+ max-width: var(--text-column);
3043
+ padding-bottom: var(--space-sm);
3044
+ border-bottom: var(--hairline-strong);
3045
+ margin-bottom: var(--space-md);
3046
+ }
3047
+
3048
+ .doc-head__path {
3049
+ color: var(--text-muted);
3050
+ font-family: var(--font-mono);
3051
+ font-size: var(--text-caption);
3052
+ overflow-wrap: anywhere;
3053
+ }
3054
+
3055
+ .doc-head__title {
3056
+ margin-top: var(--space-3xs);
3057
+ font-size: clamp(1.5rem, 2.6vw, var(--text-h2));
3058
+ font-weight: 600;
3059
+ line-height: var(--leading-h2);
3060
+ letter-spacing: -0.02em;
3061
+ }
3062
+
3063
+ .doc-head__stats {
3064
+ display: flex;
3065
+ flex-wrap: wrap;
3066
+ gap: var(--space-2xs) var(--space-xs);
3067
+ margin-top: var(--space-2xs);
3068
+ color: var(--text-muted);
3069
+ font-family: var(--font-mono);
3070
+ font-size: var(--text-caption);
3071
+ }
3072
+
3073
+ .doc {
3074
+ max-width: var(--text-column);
3075
+ color: var(--text-secondary);
3076
+ font-size: var(--text-body);
3077
+ line-height: var(--leading-body);
3078
+ }
3079
+
3080
+ .doc > * + * {
3081
+ margin-top: var(--space-sm);
3082
+ }
3083
+
3084
+ .doc h1,
3085
+ .doc h2,
3086
+ .doc h3,
3087
+ .doc h4,
3088
+ .doc h5,
3089
+ .doc h6 {
3090
+ color: var(--text);
3091
+ font-weight: 600;
3092
+ scroll-margin-top: calc(var(--header-height) + var(--space-md));
3093
+ text-wrap: balance;
3094
+ }
3095
+
3096
+ .doc h1 {
3097
+ margin-top: var(--space-lg);
3098
+ font-size: var(--text-h3);
3099
+ line-height: var(--leading-h3);
3100
+ letter-spacing: -0.015em;
3101
+ }
3102
+
3103
+ .doc h2 {
3104
+ margin-top: var(--space-lg);
3105
+ padding-top: var(--space-2xs);
3106
+ border-top: var(--hairline);
3107
+ font-size: var(--text-h4);
3108
+ line-height: var(--leading-h3);
3109
+ }
3110
+
3111
+ .doc h3 {
3112
+ margin-top: var(--space-md);
3113
+ font-size: var(--text-body);
3114
+ color: var(--text);
3115
+ }
3116
+
3117
+ .doc h4,
3118
+ .doc h5,
3119
+ .doc h6 {
3120
+ margin-top: var(--space-sm);
3121
+ font-size: var(--text-small);
3122
+ color: var(--text-secondary);
3123
+ }
3124
+
3125
+ .doc strong {
3126
+ color: var(--text);
3127
+ font-weight: 600;
3128
+ }
3129
+
3130
+ .doc em {
3131
+ font-style: italic;
3132
+ }
3133
+
3134
+ .doc del {
3135
+ color: var(--text-muted);
3136
+ }
3137
+
3138
+ .doc a {
3139
+ text-decoration-style: dashed;
3140
+ }
3141
+
3142
+ .doc ul,
3143
+ .doc ol {
3144
+ padding-left: 1.35em;
3145
+ }
3146
+
3147
+ .doc li + li {
3148
+ margin-top: 0.25em;
3149
+ }
3150
+
3151
+ .doc li > ul,
3152
+ .doc li > ol {
3153
+ margin-top: 0.25em;
3154
+ }
3155
+
3156
+ .doc li::marker {
3157
+ color: var(--text-muted);
3158
+ font-family: var(--font-mono);
3159
+ font-size: var(--text-caption);
3160
+ }
3161
+
3162
+ /* Las listas de tareas ya traen su propio control: el bullet sobra. */
3163
+ .doc li:has(> input[type='checkbox']) {
3164
+ list-style: none;
3165
+ margin-left: -1.15em;
3166
+ }
3167
+
3168
+ .doc input[type='checkbox'] {
3169
+ appearance: none;
3170
+ width: 13px;
3171
+ height: 13px;
3172
+ margin-right: 0.5em;
3173
+ border: 1px solid var(--border-input);
3174
+ vertical-align: -1px;
3175
+ }
3176
+
3177
+ .doc input[type='checkbox'][checked] {
3178
+ background: var(--accent);
3179
+ border-color: var(--accent);
3180
+ }
3181
+
3182
+ .doc blockquote {
3183
+ padding: var(--space-2xs) var(--space-sm);
3184
+ border-left: 2px solid var(--accent-border);
3185
+ background: var(--surface);
3186
+ color: var(--text-secondary);
3187
+ }
3188
+
3189
+ .doc blockquote p + p {
3190
+ margin-top: var(--space-2xs);
3191
+ }
3192
+
3193
+ .doc hr {
3194
+ height: 0;
3195
+ border: 0;
3196
+ border-top: var(--hairline-strong);
3197
+ }
3198
+
3199
+ .doc code {
3200
+ padding: 0.125em 0.375em;
3201
+ background: var(--accent-soft);
3202
+ color: var(--accent);
3203
+ font-family: var(--font-mono);
3204
+ font-size: 0.86em;
3205
+ }
3206
+
3207
+ .doc pre {
3208
+ overflow-x: auto;
3209
+ padding: var(--space-xs) var(--space-sm);
3210
+ background: var(--code-bg);
3211
+ border: 1px solid var(--border);
3212
+ scrollbar-width: thin;
3213
+ }
3214
+
3215
+ .doc pre code {
3216
+ padding: 0;
3217
+ background: none;
3218
+ color: var(--syn-variable);
3219
+ font-size: var(--text-code);
3220
+ line-height: 1.55;
3221
+ }
3222
+
3223
+ .doc table {
3224
+ width: 100%;
3225
+ border-collapse: collapse;
3226
+ font-size: var(--text-small);
3227
+ }
3228
+
3229
+ .doc th,
3230
+ .doc td {
3231
+ padding: var(--space-3xs) var(--space-2xs);
3232
+ border: 1px solid var(--border);
3233
+ text-align: left;
3234
+ vertical-align: top;
3235
+ }
3236
+
3237
+ .doc th {
3238
+ background: var(--surface);
3239
+ color: var(--text);
3240
+ font-family: var(--font-mono);
3241
+ font-size: var(--text-caption);
3242
+ letter-spacing: 0.02em;
3243
+ font-weight: 500;
3244
+ }
3245
+
3246
+ .doc td {
3247
+ font-family: var(--font-mono);
3248
+ font-size: var(--text-caption);
3249
+ }
3250
+
3251
+ .doc .md-img {
3252
+ display: inline-flex;
3253
+ padding: 0 var(--space-3xs);
3254
+ border: 1px dashed var(--border-strong);
3255
+ color: var(--text-muted);
3256
+ font-family: var(--font-mono);
3257
+ font-size: var(--text-caption);
3258
+ }
3259
+
3260
+ /* \u2500\u2500 Table of contents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3261
+ .toc {
3262
+ overflow-y: auto;
3263
+ padding: var(--space-lg) var(--space-sm);
3264
+ border-left: var(--hairline-strong);
3265
+ background: var(--surface);
3266
+ scrollbar-width: thin;
3267
+ scrollbar-color: var(--border-strong) transparent;
3268
+ }
3269
+
3270
+ .toc__title {
3271
+ color: var(--text-muted);
3272
+ font-family: var(--font-mono);
3273
+ font-size: var(--text-caption);
3274
+ letter-spacing: 0.04em;
3275
+ text-transform: uppercase;
3276
+ }
3277
+
3278
+ .toc__list {
3279
+ display: grid;
3280
+ gap: 0.125rem;
3281
+ margin-top: var(--space-2xs);
3282
+ }
3283
+
3284
+ .toc__link {
3285
+ display: block;
3286
+ padding: 0.125rem var(--space-2xs);
3287
+ border-left: 2px solid transparent;
3288
+ color: var(--text-secondary);
3289
+ font-size: var(--text-caption);
3290
+ text-decoration: none;
3291
+ overflow: hidden;
3292
+ text-overflow: ellipsis;
3293
+ white-space: nowrap;
3294
+ transition:
3295
+ color var(--duration-fast) ease,
3296
+ border-color var(--duration-fast) ease;
3297
+ }
3298
+
3299
+ .toc__link:hover {
3300
+ color: var(--text);
3301
+ border-left-color: var(--border-strong);
3302
+ }
3303
+
3304
+ .toc__link[data-level='3'] {
3305
+ padding-left: var(--space-sm);
3306
+ color: var(--text-muted);
3307
+ }
3308
+
3309
+ .toc__link[data-level='4'],
3310
+ .toc__link[data-level='5'],
3311
+ .toc__link[data-level='6'] {
3312
+ display: none;
3313
+ }
3314
+
3315
+ /* \u2500\u2500 States \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3316
+ .empty {
3317
+ display: grid;
3318
+ gap: var(--space-2xs);
3319
+ max-width: 560px;
3320
+ margin: var(--space-2xl) auto;
3321
+ padding: var(--space-lg);
3322
+ border: var(--hairline-strong);
3323
+ background: var(--surface);
3324
+ text-align: center;
3325
+ }
3326
+
3327
+ .empty__title {
3328
+ color: var(--text);
3329
+ font-size: var(--text-h4);
3330
+ font-weight: 600;
3331
+ letter-spacing: -0.01em;
3332
+ }
3333
+
3334
+ .empty__text {
3335
+ color: var(--text-secondary);
3336
+ font-size: var(--text-small);
3337
+ }
3338
+
3339
+ .empty__path {
3340
+ margin-top: var(--space-2xs);
3341
+ padding: var(--space-2xs);
3342
+ background: var(--base);
3343
+ border: 1px solid var(--border);
3344
+ color: var(--text-muted);
3345
+ font-family: var(--font-mono);
3346
+ font-size: var(--text-caption);
3347
+ overflow-wrap: anywhere;
3348
+ }
3349
+
3350
+ .empty__hint {
3351
+ color: var(--text-muted);
3352
+ font-family: var(--font-mono);
3353
+ font-size: var(--text-caption);
3354
+ }
3355
+
3356
+ .loading {
3357
+ padding: var(--space-lg) var(--space-sm);
3358
+ color: var(--text-muted);
3359
+ font-family: var(--font-mono);
3360
+ font-size: var(--text-caption);
3361
+ animation: pulse 1.6s ease-in-out infinite;
3362
+ }
3363
+
3364
+ @keyframes pulse {
3365
+ 0%,
3366
+ 100% {
3367
+ opacity: 0.45;
3368
+ }
3369
+ 50% {
3370
+ opacity: 1;
3371
+ }
3372
+ }
3373
+
3374
+ /* \u2500\u2500 Status bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3375
+ .statusbar {
3376
+ position: fixed;
3377
+ inset: auto 0 0 0;
3378
+ z-index: 40;
3379
+ display: flex;
3380
+ align-items: center;
3381
+ gap: var(--space-sm);
3382
+ height: 36px;
3383
+ padding-inline: var(--container-pad);
3384
+ background: var(--base);
3385
+ border-top: var(--hairline-strong);
3386
+ color: var(--text-muted);
3387
+ font-family: var(--font-mono);
3388
+ font-size: var(--text-caption);
3389
+ }
3390
+
3391
+ .statusbar__item {
3392
+ overflow: hidden;
3393
+ text-overflow: ellipsis;
3394
+ white-space: nowrap;
3395
+ }
3396
+
3397
+ .statusbar__spacer {
3398
+ flex: 1;
3399
+ }
3400
+
3401
+ .statusbar__dot {
3402
+ color: var(--success);
3403
+ animation: pulse 2.4s ease-in-out infinite;
3404
+ }
3405
+
3406
+ .statusbar kbd {
3407
+ padding: 0 0.25rem;
3408
+ border: 1px solid var(--border);
3409
+ color: var(--text-secondary);
3410
+ }
3411
+
3412
+ /* \u2500\u2500 Responsive: the landing page collapses the same way \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3413
+ @media (max-width: 1100px) {
3414
+ .view--specs {
3415
+ grid-template-columns: 280px minmax(0, 1fr);
3416
+ }
3417
+
3418
+ .toc {
3419
+ display: none;
3420
+ }
3421
+ }
3422
+
3423
+ @media (max-width: 820px) {
3424
+ .shell {
3425
+ padding-inline: 0;
3426
+ }
3427
+
3428
+ .topbar__inner,
3429
+ .statusbar {
3430
+ padding-inline: var(--space-sm);
3431
+ }
3432
+
3433
+ .brand__div,
3434
+ .brand__page {
3435
+ display: none;
3436
+ }
3437
+
3438
+ .view--skills,
3439
+ .view--specs {
3440
+ grid-template-columns: minmax(0, 1fr);
3441
+ }
3442
+
3443
+ .rail {
3444
+ border-right: 0;
3445
+ border-bottom: var(--hairline-strong);
3446
+ max-height: 42vh;
3447
+ }
3448
+
3449
+ .pane {
3450
+ padding: var(--space-md) var(--space-sm) var(--space-xl);
3451
+ }
3452
+
3453
+ .statusbar__hint {
3454
+ display: none;
3455
+ }
3456
+ }
3457
+
3458
+ @media (prefers-reduced-motion: reduce) {
3459
+ *,
3460
+ *::before,
3461
+ *::after {
3462
+ animation-duration: 0.01ms !important;
3463
+ animation-iteration-count: 1 !important;
3464
+ transition-duration: 0.01ms !important;
3465
+ }
3466
+ }
3467
+ `;
3468
+
3469
+ // src/studio/ui/tokens.ts
3470
+ var TOKENS_CSS = `
3471
+ :root {
3472
+ color-scheme: dark;
3473
+
3474
+ /* Surfaces */
3475
+ --base: #07090f;
3476
+ --surface: #0b0e16;
3477
+ --surface-elevated: #121623;
3478
+ --code-bg: #121623;
3479
+
3480
+ /* Text: AA on all three surfaces */
3481
+ --text: #eef2f8;
3482
+ --text-secondary: #a8b2c4;
3483
+ --text-muted: #77839a;
3484
+
3485
+ /* Electric blue accent scale */
3486
+ --accent: #8aa2ff;
3487
+ --accent-hover: #a6b8ff;
3488
+ --accent-active: #6f8ae6;
3489
+ --accent-soft: #16203a;
3490
+ --accent-soft-hover: #1d2a4d;
3491
+ --accent-border: #3d4a7a;
3492
+
3493
+ /* Semantics */
3494
+ --success: #4ade80;
3495
+ --warning: #fbbf24;
3496
+ --error: #f87171;
3497
+ --on-accent: #07090f;
3498
+
3499
+ /* Borders */
3500
+ --border: #1e2433;
3501
+ --border-strong: #2c3446;
3502
+ --border-input: #5f6b8a;
3503
+
3504
+ /* Structural lines are dashed by design; control borders stay solid */
3505
+ --hairline: 1px dashed var(--border);
3506
+ --hairline-strong: 1px dashed var(--border-strong);
3507
+
3508
+ /* Focus */
3509
+ --focus-ring: var(--accent);
3510
+ --focus-ring-offset: var(--base);
3511
+
3512
+ /* Only two shadows exist */
3513
+ --shadow-pop: 0 8px 24px rgb(0 0 0 / 0.28);
3514
+ --shadow-header: 0 1px 0 var(--border);
3515
+
3516
+ /* Scrim */
3517
+ --scrim: rgb(3 6 12 / 0.6);
3518
+
3519
+ /* Typography */
3520
+ --font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI',
3521
+ Roboto, sans-serif;
3522
+ --font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace,
3523
+ SFMono-Regular, Menlo, Consolas, monospace;
3524
+
3525
+ --text-display: 3.75rem;
3526
+ --text-h1: 2.75rem;
3527
+ --text-h2: 2.125rem;
3528
+ --text-h3: 1.5rem;
3529
+ --text-h4: 1.1875rem;
3530
+ --text-body: 1.0625rem;
3531
+ --text-small: 0.9375rem;
3532
+ --text-caption: 0.8125rem;
3533
+ --text-code: 0.875rem;
3534
+
3535
+ --leading-display: 1.1;
3536
+ --leading-h1: 1.15;
3537
+ --leading-h2: 1.2;
3538
+ --leading-h3: 1.3;
3539
+ --leading-body: 1.6;
3540
+
3541
+ /* Spacing */
3542
+ --space-3xs: 0.25rem;
3543
+ --space-2xs: 0.5rem;
3544
+ --space-xs: 0.75rem;
3545
+ --space-sm: 1rem;
3546
+ --space-md: 1.5rem;
3547
+ --space-lg: 2rem;
3548
+ --space-xl: 3rem;
3549
+ --space-2xl: 4rem;
3550
+ --space-3xl: 6rem;
3551
+ --space-4xl: 7.5rem;
3552
+
3553
+ /* Radii: square corners everywhere, roles kept for a one-line rollback */
3554
+ --radius-sm: 0px;
3555
+ --radius-md: 0px;
3556
+ --radius-pill: 0px;
3557
+
3558
+ /* Layout */
3559
+ --container: 1300px;
3560
+ --container-pad: 48px;
3561
+ --text-column: 800px;
3562
+ --hero-column: 560px;
3563
+ --header-height: 72px;
3564
+
3565
+ /* Motion */
3566
+ --duration-fast: 150ms;
3567
+ --duration-base: 250ms;
3568
+ --duration-reveal: 400ms;
3569
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
3570
+
3571
+ /* Syntax, >= 4.5 on --code-bg in both themes */
3572
+ --syn-comment: #77839a;
3573
+ --syn-keyword: #a78bfa;
3574
+ --syn-string: #86efac;
3575
+ --syn-number: #fbbf24;
3576
+ --syn-function: #7dd3fc;
3577
+ --syn-type: #f0abfc;
3578
+ --syn-variable: #e6edf7;
3579
+ --syn-tag: #f87171;
3580
+ --syn-operator: #9aa5b8;
3581
+ --syn-punctuation: #8b95a8;
3582
+ }
3583
+
3584
+ /* Light theme: same roles, measured values. Both blocks are the same theme:
3585
+ one answers the system preference when the user has not chosen, the other
3586
+ answers the stored choice. Keep them identical. */
3587
+ [data-theme='light'] {
3588
+ color-scheme: light;
3589
+
3590
+ --base: #ffffff;
3591
+ --surface: #f7f8fb;
3592
+ --surface-elevated: #eef1f7;
3593
+ --code-bg: #eef1f7;
3594
+
3595
+ --text: #0d1220;
3596
+ --text-secondary: #454f63;
3597
+ --text-muted: #5c6678;
3598
+
3599
+ --accent: #3d54d4;
3600
+ --accent-hover: #2b3ea8;
3601
+ --accent-active: #24337f;
3602
+ --accent-soft: #eaeeff;
3603
+ --accent-soft-hover: #dde3ff;
3604
+ --accent-border: #b9c2ee;
3605
+
3606
+ --success: #166534;
3607
+ --warning: #78350f;
3608
+ --error: #b91c1c;
3609
+ --on-accent: #ffffff;
3610
+
3611
+ --border: #dfe3ec;
3612
+ --border-strong: #c3cad8;
3613
+ --border-input: #828b9c;
3614
+
3615
+ --shadow-pop: 0 8px 24px rgb(13 18 32 / 0.1);
3616
+ --scrim: rgb(13 18 32 / 0.5);
3617
+
3618
+ --syn-comment: #5c6678;
3619
+ --syn-keyword: #7c3aed;
3620
+ --syn-string: #166534;
3621
+ --syn-number: #92400e;
3622
+ --syn-function: #0e7490;
3623
+ --syn-type: #a21caf;
3624
+ --syn-variable: #0d1220;
3625
+ --syn-tag: #b91c1c;
3626
+ --syn-operator: #5c6678;
3627
+ --syn-punctuation: #5c6678;
3628
+ }
3629
+
3630
+ @media (prefers-color-scheme: light) {
3631
+ :root:not([data-theme]) {
3632
+ color-scheme: light;
3633
+
3634
+ --base: #ffffff;
3635
+ --surface: #f7f8fb;
3636
+ --surface-elevated: #eef1f7;
3637
+ --code-bg: #eef1f7;
3638
+
3639
+ --text: #0d1220;
3640
+ --text-secondary: #454f63;
3641
+ --text-muted: #5c6678;
3642
+
3643
+ --accent: #3d54d4;
3644
+ --accent-hover: #2b3ea8;
3645
+ --accent-active: #24337f;
3646
+ --accent-soft: #eaeeff;
3647
+ --accent-soft-hover: #dde3ff;
3648
+ --accent-border: #b9c2ee;
3649
+
3650
+ --success: #166534;
3651
+ --warning: #78350f;
3652
+ --error: #b91c1c;
3653
+ --on-accent: #ffffff;
3654
+
3655
+ --border: #dfe3ec;
3656
+ --border-strong: #c3cad8;
3657
+ --border-input: #828b9c;
3658
+
3659
+ --shadow-pop: 0 8px 24px rgb(13 18 32 / 0.1);
3660
+ --scrim: rgb(13 18 32 / 0.5);
3661
+
3662
+ --syn-comment: #5c6678;
3663
+ --syn-keyword: #7c3aed;
3664
+ --syn-string: #166534;
3665
+ --syn-number: #92400e;
3666
+ --syn-function: #0e7490;
3667
+ --syn-type: #a21caf;
3668
+ --syn-variable: #0d1220;
3669
+ --syn-tag: #b91c1c;
3670
+ --syn-operator: #5c6678;
3671
+ --syn-punctuation: #5c6678;
3672
+ }
3673
+ }
3674
+ `;
3675
+
3676
+ // src/studio/ui/document.ts
3677
+ var THEME_BOOTSTRAP = `(function(){try{var t=localStorage.getItem('fwinit:theme');if(t==='light'||t==='dark'){document.documentElement.dataset.theme=t;}}catch(e){}})();`;
3678
+ var ICON = {
3679
+ search: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/></svg>',
3680
+ moon: '<svg class="icon-moon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M13.2 9.4A5.6 5.6 0 0 1 6.6 2.8a5.6 5.6 0 1 0 6.6 6.6Z"/></svg>',
3681
+ sun: '<svg class="icon-sun" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><circle cx="8" cy="8" r="3.2"/><path d="M8 1v1.6M8 13.4V15M1 8h1.6M13.4 8H15M3.05 3.05l1.13 1.13M11.82 11.82l1.13 1.13M12.95 3.05l-1.13 1.13M4.18 11.82l-1.13 1.13"/></svg>'
3682
+ };
3683
+ function escapeHtml(value) {
3684
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3685
+ }
3686
+ var FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2316203a'/%3E%3Crect x='0.5' y='0.5' width='31' height='31' fill='none' stroke='%238aa2ff'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' font-weight='700' fill='%238aa2ff' text-anchor='middle'%3ES%3C/text%3E%3C/svg%3E";
3687
+ function renderStudioHtml(options) {
3688
+ const meta = JSON.stringify({
3689
+ version: options.version,
3690
+ projectRoot: options.projectRoot,
3691
+ home: options.home ?? ""
3692
+ }).replace(/</g, "\\u003c");
3693
+ return `<!doctype html>
3694
+ <html lang="es">
3695
+ <head>
3696
+ <meta charset="utf-8" />
3697
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3698
+ <meta name="color-scheme" content="dark light" />
3699
+ <meta name="robots" content="noindex, nofollow" />
3700
+ <title>fwinit studio</title>
3701
+ <meta name="description" content="Vista local de las skills instaladas para opencode, pi y Gentle Shell, y de las specs de este proyecto." />
3702
+ <link rel="icon" href="${FAVICON}" />
3703
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
3704
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
3705
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..700;1,14..32,400..600&family=JetBrains+Mono:wght@400;500;600&display=swap" />
3706
+ <script>${THEME_BOOTSTRAP}</script>
3707
+ <style>${TOKENS_CSS}</style>
3708
+ <style>${STUDIO_CSS}</style>
3709
+ </head>
3710
+ <body>
3711
+ <a class="skip" href="#main">Saltar al contenido</a>
3712
+ <header class="topbar">
3713
+ <div class="topbar__inner">
3714
+ <a class="brand" href="#/skills" aria-label="fwinit studio, inicio">
3715
+ <span class="brand__glyph" aria-hidden="true">S</span>
3716
+ <span class="brand__name">fwinit</span>
3717
+ <span class="brand__div" aria-hidden="true">/</span>
3718
+ <span class="brand__page">studio</span>
3719
+ </a>
3720
+ <nav class="tabs" aria-label="Secciones">
3721
+ <button type="button" class="tab" role="tab" data-view="skills" aria-selected="true">
3722
+ Skills <span class="tab__count" data-count="skills">\u2014</span>
3723
+ </button>
3724
+ <button type="button" class="tab" role="tab" data-view="specs" aria-selected="false">
3725
+ Specs <span class="tab__count" data-count="specs">\u2014</span>
3726
+ </button>
3727
+ </nav>
3728
+ <div class="topbar__actions">
3729
+ <span class="badge badge--version">v${escapeHtml(options.version)}</span>
3730
+ <button type="button" class="btn btn--icon theme-toggle" data-theme-toggle aria-pressed="false" aria-label="Cambiar al tema claro">
3731
+ ${ICON.moon}${ICON.sun}
3732
+ </button>
3733
+ </div>
3734
+ </div>
3735
+ </header>
3736
+
3737
+ <main id="main" class="shell">
3738
+ <section class="view view--skills" data-view-panel="skills" data-active="true" aria-label="Skills instaladas">
3739
+ <aside class="rail">
3740
+ <div class="rail__head">
3741
+ <div class="field">
3742
+ ${ICON.search}
3743
+ <input class="field__input" type="search" data-search="skills" placeholder="Filtrar skills" aria-label="Filtrar skills" autocomplete="off" spellcheck="false" />
3744
+ <kbd>/</kbd>
3745
+ </div>
3746
+ <div class="segmented" role="group" aria-label="Alcance">
3747
+ <button type="button" data-scope="global" aria-pressed="true">Globales</button>
3748
+ <button type="button" data-scope="project" aria-pressed="false">Proyecto</button>
3749
+ </div>
3750
+ </div>
3751
+ <div class="rail__body" data-list="skills">
3752
+ <p class="loading">Leyendo las skills instaladas\u2026</p>
3753
+ </div>
3754
+ </aside>
3755
+ <section class="pane" data-detail="skills">
3756
+ <p class="loading">Leyendo las skills instaladas\u2026</p>
3757
+ </section>
3758
+ </section>
3759
+
3760
+ <section class="view view--specs" data-view-panel="specs" data-active="false" aria-label="Specs del proyecto">
3761
+ <aside class="rail">
3762
+ <div class="rail__head">
3763
+ <div class="field">
3764
+ ${ICON.search}
3765
+ <input class="field__input" type="search" data-search="specs" placeholder="Filtrar specs" aria-label="Filtrar specs" autocomplete="off" spellcheck="false" />
3766
+ <kbd>/</kbd>
3767
+ </div>
3768
+ </div>
3769
+ <div class="rail__body" data-list="specs">
3770
+ <p class="loading">Buscando specs/\u2026</p>
3771
+ </div>
3772
+ </aside>
3773
+ <section class="pane" data-detail="specs">
3774
+ <p class="loading">Buscando specs/\u2026</p>
3775
+ </section>
3776
+ <aside class="toc" data-toc aria-label="En esta p\xE1gina">
3777
+ <p class="toc__title">En esta p\xE1gina</p>
3778
+ <div class="toc__list" data-toc-list></div>
3779
+ </aside>
3780
+ </section>
3781
+ </main>
3782
+
3783
+ <footer class="statusbar">
3784
+ <span class="statusbar__dot" aria-hidden="true">\u25CF</span>
3785
+ <span class="statusbar__item" data-status-root title="">${escapeHtml(options.projectRoot)}</span>
3786
+ <span class="statusbar__spacer"></span>
3787
+ <span class="statusbar__item" data-status-counts></span>
3788
+ <span class="statusbar__item statusbar__hint"><kbd>/</kbd> filtrar \xB7 <kbd>1</kbd><kbd>2</kbd> vista \xB7 <kbd>t</kbd> tema</span>
3789
+ </footer>
3790
+
3791
+ <script type="application/json" id="studio-meta">${meta}</script>
3792
+ <script>${CLIENT_JS}</script>
3793
+ </body>
3794
+ </html>
3795
+ `;
3796
+ }
3797
+
3798
+ // src/studio/open-browser.ts
3799
+ import { spawn, spawnSync } from "child_process";
3800
+ function openBrowser(url) {
3801
+ const platform = process.platform;
3802
+ if (platform === "darwin") {
3803
+ return trySpawn("open", [url]);
3804
+ }
3805
+ if (platform === "win32") {
3806
+ const result = spawnSync("cmd", ["/c", "start", '""', url], {
3807
+ stdio: "ignore",
3808
+ windowsHide: true
3809
+ });
3810
+ return !result.error && result.status === 0;
3811
+ }
3812
+ for (const command of ["xdg-open", "gio", "sensible-browser", "x-www-browser"]) {
3813
+ const args = command === "gio" ? ["open", url] : [url];
3814
+ if (trySpawn(command, args)) return true;
3815
+ }
3816
+ const configured = process.env.BROWSER?.trim();
3817
+ if (configured) return trySpawn(configured, [url]);
3818
+ return false;
3819
+ }
3820
+ function trySpawn(command, args) {
3821
+ try {
3822
+ const child = spawn(command, args, {
3823
+ stdio: "ignore",
3824
+ detached: true,
3825
+ windowsHide: true
3826
+ });
3827
+ child.on("error", () => {
3828
+ });
3829
+ child.unref();
3830
+ return true;
3831
+ } catch {
3832
+ return false;
3833
+ }
3834
+ }
3835
+
3836
+ // src/studio/server.ts
3837
+ var HOST = "127.0.0.1";
3838
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
3839
+ function isAllowedHost(header, port) {
3840
+ if (typeof header !== "string") return false;
3841
+ const value = header.trim().toLowerCase();
3842
+ if (value === "") return false;
3843
+ if (value.startsWith("[")) {
3844
+ const close = value.indexOf("]");
3845
+ if (close === -1) return false;
3846
+ const hostname2 = value.slice(0, close + 1);
3847
+ const rest = value.slice(close + 1);
3848
+ if (!LOOPBACK_HOSTNAMES.has(hostname2)) return false;
3849
+ if (rest === "") return true;
3850
+ return rest.startsWith(":") && rest.slice(1) !== "" && rest.slice(1) === String(port);
3851
+ }
3852
+ const colon = value.lastIndexOf(":");
3853
+ const hostname = colon === -1 ? value : value.slice(0, colon);
3854
+ if (!LOOPBACK_HOSTNAMES.has(hostname)) return false;
3855
+ if (colon === -1) return true;
3856
+ const requestedPort = value.slice(colon + 1);
3857
+ return requestedPort !== "" && requestedPort === String(port);
3858
+ }
3859
+ var SECURITY_HEADERS = {
3860
+ "X-Content-Type-Options": "nosniff",
3861
+ "X-Frame-Options": "DENY",
3862
+ "Referrer-Policy": "no-referrer",
3863
+ "Cross-Origin-Opener-Policy": "same-origin",
3864
+ // Everything is inline by design: one document, one stylesheet, one script.
3865
+ // Only the font files are remote, and only from Google Fonts.
3866
+ "Content-Security-Policy": [
3867
+ "default-src 'none'",
3868
+ "base-uri 'none'",
3869
+ "form-action 'none'",
3870
+ "frame-ancestors 'none'",
3871
+ "img-src data:",
3872
+ "style-src 'unsafe-inline' https://fonts.googleapis.com",
3873
+ "font-src https://fonts.gstatic.com",
3874
+ "script-src 'unsafe-inline'",
3875
+ "connect-src 'self'"
3876
+ ].join("; ")
3877
+ };
3878
+ var SPEC_ERROR_STATUS = {
3879
+ invalid: 400,
3880
+ outside: 403,
3881
+ missing: 404,
3882
+ unsupported: 415
3883
+ };
3884
+ function sendJson(response, status, body) {
3885
+ const payload = JSON.stringify(body);
3886
+ response.writeHead(status, {
3887
+ ...SECURITY_HEADERS,
3888
+ "Content-Type": "application/json; charset=utf-8",
3889
+ "Content-Length": Buffer.byteLength(payload),
3890
+ "Cache-Control": "no-store"
3891
+ });
3892
+ response.end(payload);
3893
+ }
3894
+ function sendHtml(response, html) {
3895
+ response.writeHead(200, {
3896
+ ...SECURITY_HEADERS,
3897
+ "Content-Type": "text/html; charset=utf-8",
3898
+ "Content-Length": Buffer.byteLength(html),
3899
+ "Cache-Control": "no-store"
3900
+ });
3901
+ response.end(html);
3902
+ }
3903
+ function sendText(response, status, text) {
3904
+ response.writeHead(status, {
3905
+ ...SECURITY_HEADERS,
3906
+ "Content-Type": "text/plain; charset=utf-8",
3907
+ "Content-Length": Buffer.byteLength(text),
3908
+ "Cache-Control": "no-store"
3909
+ });
3910
+ response.end(text);
3911
+ }
3912
+ async function startStudio(options = {}) {
3913
+ const projectRoot = resolve3(options.projectRoot ?? process.cwd());
3914
+ const version = options.version ?? "0.0.0";
3915
+ const home = options.home;
3916
+ let listeningPort = options.port ?? 0;
3917
+ const server = createServer((request, response) => {
3918
+ handle(request, response).catch((error) => {
3919
+ if (response.headersSent) {
3920
+ response.end();
3921
+ return;
3922
+ }
3923
+ const message = error instanceof Error ? error.message : String(error);
3924
+ process.stderr.write(`fwinit studio: ${message}
3925
+ `);
3926
+ sendJson(response, 500, { error: "No se pudo completar la solicitud." });
3927
+ });
3928
+ });
3929
+ async function handle(request, response) {
3930
+ const method = request.method ?? "GET";
3931
+ if (method !== "GET" && method !== "HEAD") {
3932
+ response.setHeader("Allow", "GET, HEAD");
3933
+ sendJson(response, 405, { error: "M\xE9todo no permitido." });
3934
+ return;
3935
+ }
3936
+ const hostPort = listeningPort || request.socket.localPort || 0;
3937
+ if (!isAllowedHost(request.headers.host, hostPort)) {
3938
+ sendJson(response, 403, { error: "Host no permitido." });
3939
+ return;
3940
+ }
3941
+ const url2 = new URL(request.url ?? "/", `http://${HOST}`);
3942
+ let pathname;
3943
+ try {
3944
+ pathname = decodeURIComponent(url2.pathname);
3945
+ } catch {
3946
+ sendJson(response, 400, { error: "Ruta inv\xE1lida." });
3947
+ return;
3948
+ }
3949
+ if (pathname === "/" || pathname === "/index.html") {
3950
+ sendHtml(
3951
+ response,
3952
+ renderStudioHtml({
3953
+ version,
3954
+ projectRoot,
3955
+ home: home ?? process.env.HOME ?? ""
3956
+ })
3957
+ );
3958
+ return;
3959
+ }
3960
+ if (pathname === "/health") {
3961
+ sendJson(response, 200, { ok: true, projectRoot });
3962
+ return;
3963
+ }
3964
+ if (pathname === "/api/meta") {
3965
+ sendJson(response, 200, {
3966
+ version,
3967
+ projectRoot,
3968
+ home: home ?? process.env.HOME ?? "",
3969
+ platform: process.platform
3970
+ });
3971
+ return;
3972
+ }
3973
+ if (pathname === "/api/skills") {
3974
+ sendJson(response, 200, await discoverSkills({ projectRoot, home }));
3975
+ return;
3976
+ }
3977
+ if (pathname === "/api/specs") {
3978
+ sendJson(response, 200, await buildSpecsTree(projectRoot));
3979
+ return;
3980
+ }
3981
+ if (pathname === "/api/specs/file") {
3982
+ const relativePath = url2.searchParams.get("path");
3983
+ if (!relativePath) {
3984
+ sendJson(response, 400, { error: "Falta el par\xE1metro path." });
3985
+ return;
3986
+ }
3987
+ const specsRoot = join4(projectRoot, "specs");
3988
+ try {
3989
+ const file = await readSpecFile({ specsRoot, projectRoot }, relativePath);
3990
+ sendJson(response, 200, file);
3991
+ } catch (error) {
3992
+ if (error instanceof SpecPathError) {
3993
+ sendJson(response, SPEC_ERROR_STATUS[error.code] ?? 400, {
3994
+ error: error.message,
3995
+ code: error.code
3996
+ });
3997
+ return;
3998
+ }
3999
+ throw error;
721
4000
  }
4001
+ return;
722
4002
  }
4003
+ sendText(response, 404, "Not found");
723
4004
  }
724
- return { installs, skillDirs, commandDirs: ["commands"], missingRequired };
4005
+ await new Promise((resolveListen, rejectListen) => {
4006
+ const onError = (error) => {
4007
+ server.off("listening", onListening);
4008
+ rejectListen(error);
4009
+ };
4010
+ const onListening = () => {
4011
+ server.off("error", onError);
4012
+ resolveListen();
4013
+ };
4014
+ server.once("error", onError);
4015
+ server.once("listening", onListening);
4016
+ server.listen(options.port ?? 0, HOST);
4017
+ });
4018
+ const address = server.address();
4019
+ const port = address?.port ?? 0;
4020
+ listeningPort = port;
4021
+ const url = `http://${HOST}:${port}`;
4022
+ const opened = options.open === false ? false : openBrowser(url);
4023
+ void opened;
4024
+ return {
4025
+ address: { url, host: HOST, port },
4026
+ close: () => new Promise((resolveClose) => {
4027
+ server.close(() => resolveClose());
4028
+ server.closeAllConnections?.();
4029
+ })
4030
+ };
725
4031
  }
726
4032
 
727
4033
  // src/prompts.ts
@@ -872,24 +4178,24 @@ async function askGlobalSkillsToo() {
872
4178
  }
873
4179
 
874
4180
  // src/global-skills.ts
875
- import { mkdir as mkdir2, cp as cp2, rm as rm2, readdir as readdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
876
- import { join as join2 } from "path";
4181
+ import { mkdir as mkdir2, cp as cp2, rm as rm2, readdir as readdir4, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
4182
+ import { join as join5 } from "path";
877
4183
  function resolveGlobalTargets() {
878
4184
  const home = process.env.HOME;
879
4185
  if (!home) return [];
880
- const gentleShellRoot = process.env.GENTLE_SHELL_HOME || join2(home, ".gentle-shell", "agent");
4186
+ const gentleShellRoot = process.env.GENTLE_SHELL_HOME || join5(home, ".gentle-shell", "agent");
881
4187
  return [
882
4188
  {
883
4189
  id: "opencode",
884
4190
  label: `OpenCode global (${home}/.config/opencode)`,
885
- root: join2(home, ".config", "opencode"),
4191
+ root: join5(home, ".config", "opencode"),
886
4192
  skillsDir: "skills",
887
4193
  commandsDir: "command"
888
4194
  },
889
4195
  {
890
4196
  id: "pi",
891
4197
  label: `Pi global (${home}/.pi/agent)`,
892
- root: join2(home, ".pi", "agent"),
4198
+ root: join5(home, ".pi", "agent"),
893
4199
  skillsDir: "skills",
894
4200
  commandsDir: "prompts"
895
4201
  },
@@ -903,7 +4209,7 @@ function resolveGlobalTargets() {
903
4209
  {
904
4210
  id: "agents",
905
4211
  label: `Agent Skills global (${home}/.agents/skills)`,
906
- root: join2(home, ".agents"),
4212
+ root: join5(home, ".agents"),
907
4213
  skillsDir: "skills"
908
4214
  }
909
4215
  ];
@@ -917,11 +4223,11 @@ function filterBundleByNames(only) {
917
4223
  async function ensureGlobalOpencodeConfig() {
918
4224
  const home = process.env.HOME;
919
4225
  if (!home) return;
920
- const configPath = join2(home, ".config", "opencode", "opencode.json");
4226
+ const configPath = join5(home, ".config", "opencode", "opencode.json");
921
4227
  try {
922
4228
  let data;
923
4229
  try {
924
- const content = await readFile2(configPath, "utf-8");
4230
+ const content = await readFile4(configPath, "utf-8");
925
4231
  data = JSON.parse(content);
926
4232
  } catch {
927
4233
  data = { agent: {} };
@@ -951,7 +4257,7 @@ function mapPlanToTarget(plan, target) {
951
4257
  if (item.to.startsWith("skills/")) {
952
4258
  mapped.push({
953
4259
  from: item.from,
954
- to: join2(target.root, target.skillsDir, item.to.slice("skills/".length)),
4260
+ to: join5(target.root, target.skillsDir, item.to.slice("skills/".length)),
955
4261
  recursive: item.recursive
956
4262
  });
957
4263
  continue;
@@ -960,7 +4266,7 @@ function mapPlanToTarget(plan, target) {
960
4266
  if (!target.commandsDir) continue;
961
4267
  mapped.push({
962
4268
  from: item.from,
963
- to: join2(target.root, target.commandsDir, item.to.slice("commands/".length)),
4269
+ to: join5(target.root, target.commandsDir, item.to.slice("commands/".length)),
964
4270
  recursive: item.recursive
965
4271
  });
966
4272
  continue;
@@ -979,7 +4285,7 @@ async function installGlobalSkills(repoRoot, targets, bundle = AGENT_BUNDLE) {
979
4285
  await rm2(item.to, { recursive: true, force: true });
980
4286
  }
981
4287
  await mkdir2(
982
- item.recursive === true ? item.to : join2(item.to, ".."),
4288
+ item.recursive === true ? item.to : join5(item.to, ".."),
983
4289
  { recursive: true }
984
4290
  );
985
4291
  await cp2(item.from, item.to, {
@@ -1005,22 +4311,22 @@ async function uninstallGlobalSkills(targets, bundle = AGENT_BUNDLE) {
1005
4311
  try {
1006
4312
  const removed = [];
1007
4313
  for (const name of skillNames) {
1008
- await rm2(join2(target.root, target.skillsDir, name), {
4314
+ await rm2(join5(target.root, target.skillsDir, name), {
1009
4315
  recursive: true,
1010
4316
  force: true
1011
4317
  });
1012
4318
  removed.push(`${target.skillsDir}/${name}`);
1013
4319
  }
1014
4320
  if (target.commandsDir) {
1015
- const cmdDir = join2(target.root, target.commandsDir);
4321
+ const cmdDir = join5(target.root, target.commandsDir);
1016
4322
  for (const name of skillNames) {
1017
- await rm2(join2(cmdDir, `${name}.md`), { force: true });
4323
+ await rm2(join5(cmdDir, `${name}.md`), { force: true });
1018
4324
  removed.push(`${target.commandsDir}/${name}.md`);
1019
4325
  try {
1020
4326
  const prefix = `${name}-`;
1021
- for (const f of await readdir2(cmdDir)) {
4327
+ for (const f of await readdir4(cmdDir)) {
1022
4328
  if (f.startsWith(prefix) && f.endsWith(".md")) {
1023
- await rm2(join2(cmdDir, f), { force: true });
4329
+ await rm2(join5(cmdDir, f), { force: true });
1024
4330
  removed.push(`${target.commandsDir}/${f}`);
1025
4331
  }
1026
4332
  }
@@ -1038,7 +4344,7 @@ async function uninstallGlobalSkills(targets, bundle = AGENT_BUNDLE) {
1038
4344
 
1039
4345
  // src/utils.ts
1040
4346
  import { existsSync } from "fs";
1041
- import { resolve } from "path";
4347
+ import { resolve as resolve4 } from "path";
1042
4348
  var PM_RUNNER = {
1043
4349
  npm: "npm",
1044
4350
  pnpm: "pnpm",
@@ -1069,7 +4375,7 @@ var BUN_SUBCOMMANDS = /* @__PURE__ */ new Set([
1069
4375
  "rebuild"
1070
4376
  ]);
1071
4377
  function getProjectPath(name) {
1072
- return resolve(process.cwd(), name);
4378
+ return resolve4(process.cwd(), name);
1073
4379
  }
1074
4380
  function projectExists(name) {
1075
4381
  return existsSync(getProjectPath(name));
@@ -1138,10 +4444,10 @@ function adaptScriptToNode(script) {
1138
4444
  return s;
1139
4445
  }
1140
4446
  async function adaptToNodeRuntime(projectPath) {
1141
- const { readFile: readFile4, writeFile: writeFile3, readdir: readdir3 } = await import("fs/promises");
1142
- const { join: join4 } = await import("path");
1143
- const pkgPath = join4(projectPath, "package.json");
1144
- const pkg = JSON.parse(await readFile4(pkgPath, "utf-8"));
4447
+ const { readFile: readFile6, writeFile: writeFile3, readdir: readdir5 } = await import("fs/promises");
4448
+ const { join: join7 } = await import("path");
4449
+ const pkgPath = join7(projectPath, "package.json");
4450
+ const pkg = JSON.parse(await readFile6(pkgPath, "utf-8"));
1145
4451
  for (const key of Object.keys(pkg.scripts ?? {})) {
1146
4452
  pkg.scripts[key] = adaptScriptToNode(pkg.scripts[key]);
1147
4453
  }
@@ -1151,27 +4457,27 @@ async function adaptToNodeRuntime(projectPath) {
1151
4457
  delete pkg.devDependencies["bun-types"];
1152
4458
  await writeFile3(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
1153
4459
  async function walk(dir) {
1154
- const entries = await readdir3(dir, { withFileTypes: true });
4460
+ const entries = await readdir5(dir, { withFileTypes: true });
1155
4461
  for (const entry of entries) {
1156
- const full = join4(dir, entry.name);
4462
+ const full = join7(dir, entry.name);
1157
4463
  if (entry.isDirectory()) {
1158
4464
  if (entry.name !== "node_modules") await walk(full);
1159
4465
  } else if (entry.name.endsWith(".test.ts")) {
1160
- const content = await readFile4(full, "utf-8");
4466
+ const content = await readFile6(full, "utf-8");
1161
4467
  if (content.includes("bun:test")) {
1162
4468
  await writeFile3(full, content.replaceAll('from "bun:test"', 'from "vitest"'));
1163
4469
  }
1164
4470
  }
1165
4471
  }
1166
4472
  }
1167
- const srcDir = join4(projectPath, "src");
4473
+ const srcDir = join7(projectPath, "src");
1168
4474
  try {
1169
4475
  await walk(srcDir);
1170
4476
  } catch {
1171
4477
  }
1172
- const tsconfigPath = join4(projectPath, "tsconfig.json");
4478
+ const tsconfigPath = join7(projectPath, "tsconfig.json");
1173
4479
  try {
1174
- const tsconfig = JSON.parse(await readFile4(tsconfigPath, "utf-8"));
4480
+ const tsconfig = JSON.parse(await readFile6(tsconfigPath, "utf-8"));
1175
4481
  if (tsconfig.compilerOptions?.types?.includes("bun-types")) {
1176
4482
  delete tsconfig.compilerOptions.types;
1177
4483
  await writeFile3(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
@@ -1181,8 +4487,8 @@ async function adaptToNodeRuntime(projectPath) {
1181
4487
  }
1182
4488
  async function writeRootGitignore(projectPath) {
1183
4489
  const { writeFile: writeFile3, access } = await import("fs/promises");
1184
- const { join: join4 } = await import("path");
1185
- const gitignorePath = join4(projectPath, ".gitignore");
4490
+ const { join: join7 } = await import("path");
4491
+ const gitignorePath = join7(projectPath, ".gitignore");
1186
4492
  try {
1187
4493
  await access(gitignorePath);
1188
4494
  } catch {
@@ -1195,12 +4501,12 @@ async function writeRootGitignore(projectPath) {
1195
4501
  async function gitInit(projectPath) {
1196
4502
  const { execFile } = await import("child_process");
1197
4503
  try {
1198
- await new Promise((resolve2, reject) => {
4504
+ await new Promise((resolve5, reject) => {
1199
4505
  execFile(
1200
4506
  "git",
1201
4507
  ["init"],
1202
4508
  { cwd: projectPath },
1203
- (err) => err ? reject(err) : resolve2()
4509
+ (err) => err ? reject(err) : resolve5()
1204
4510
  );
1205
4511
  });
1206
4512
  return true;
@@ -1210,8 +4516,8 @@ async function gitInit(projectPath) {
1210
4516
  }
1211
4517
  async function writeRootFiles(info) {
1212
4518
  const { writeFile: writeFile3 } = await import("fs/promises");
1213
- const { join: join4 } = await import("path");
1214
- const projectPath = resolve(process.cwd(), info.projectName);
4519
+ const { join: join7 } = await import("path");
4520
+ const projectPath = resolve4(process.cwd(), info.projectName);
1215
4521
  await writeRootGitignore(projectPath);
1216
4522
  const installB = info.backend.postInit?.install ? resolveCommand(info.backend.postInit.install, info.pm) : `${info.pm} install`;
1217
4523
  const devB = info.backend.postInit?.dev ? resolveCommand(info.backend.postInit.dev, info.pm) : `${info.pm} run dev`;
@@ -1226,7 +4532,7 @@ async function writeRootFiles(info) {
1226
4532
  "",
1227
4533
  `- \`backend/\` \u2014 ${info.backend.description}`,
1228
4534
  `- \`frontend/\` \u2014 ${info.frontend.description}`,
1229
- "- `.opencode/`, `.pi/` y `.agents/` \u2014 skills de agentes: `/create-prompt` para detallar tu idea, `/create-specs` para generar las specs, `/create-specs-from-code` para documentar un proyecto que ya existe, + biblioteca de dise\xF1o (dark-luxury, minimal-dashboard, minimal-light, neo-brutalist, glassmorphism). Mismo bundle en las 3 ubicaciones: OpenCode lee `.opencode/`; Pi y Gentle Shell leen `.pi/` (skills + prompt templates); los agentes Agent Skills leen `.agents/`",
4535
+ "- `.opencode/`, `.pi/` y `.agents/` \u2014 skills de agentes: `/create-prompt` para detallar tu idea, `/create-specs` para generar las specs, `/create-specs-from-code` para documentar un proyecto que ya existe, + biblioteca de dise\xF1o con `/design` (estilos dark-luxury, docs-starship, minimal-dashboard, minimal-light, neo-brutalist, glassmorphism) y el cat\xE1logo search-driven `/design-ui-ux-pro-max` (requiere Python 3). Mismo bundle en las 3 ubicaciones: OpenCode lee `.opencode/`; Pi y Gentle Shell leen `.pi/` (skills + prompt templates); los agentes Agent Skills leen `.agents/`",
1230
4536
  "- `specs/` \u2014 especificaciones del proyecto",
1231
4537
  "",
1232
4538
  "## Backend",
@@ -1246,7 +4552,7 @@ async function writeRootFiles(info) {
1246
4552
  "```",
1247
4553
  ""
1248
4554
  ].join("\n");
1249
- await writeFile3(join4(projectPath, "README.md"), readme);
4555
+ await writeFile3(join7(projectPath, "README.md"), readme);
1250
4556
  }
1251
4557
 
1252
4558
  // src/index.ts
@@ -1265,15 +4571,15 @@ var AGENT_DIRS = [".opencode", ".pi", ".agents"];
1265
4571
  async function installAgentSkills(repoRoot, projectPath) {
1266
4572
  const plan = await planAgentBundle(repoRoot);
1267
4573
  for (const agentDir of AGENT_DIRS) {
1268
- const root = join3(projectPath, agentDir);
4574
+ const root = join6(projectPath, agentDir);
1269
4575
  for (const dir of plan.skillDirs) {
1270
- await mkdir3(join3(root, dir), { recursive: true });
4576
+ await mkdir3(join6(root, dir), { recursive: true });
1271
4577
  }
1272
4578
  for (const dir of plan.commandDirs) {
1273
- await mkdir3(join3(root, dir), { recursive: true });
4579
+ await mkdir3(join6(root, dir), { recursive: true });
1274
4580
  }
1275
4581
  for (const item of plan.installs) {
1276
- await cp3(item.from, join3(root, item.to), {
4582
+ await cp3(item.from, join6(root, item.to), {
1277
4583
  recursive: item.recursive === true
1278
4584
  });
1279
4585
  }
@@ -1310,7 +4616,7 @@ async function installGlobalSkillsWithSpinner(targets, bundle) {
1310
4616
  async function readTemplatePostInit(dir) {
1311
4617
  try {
1312
4618
  const tj = JSON.parse(
1313
- await readFile3(join3(dir, "template.json"), "utf-8")
4619
+ await readFile5(join6(dir, "template.json"), "utf-8")
1314
4620
  );
1315
4621
  return tj.postInit;
1316
4622
  } catch {
@@ -1320,14 +4626,14 @@ async function readTemplatePostInit(dir) {
1320
4626
  async function createFullStackProject(projectPath, backendTpl, frontendTpl, pm, org) {
1321
4627
  const { tempDir, repoRoot } = await downloadAndExtractRepo();
1322
4628
  const projectName = basename2(projectPath);
1323
- const backendPath = join3(projectPath, "backend");
1324
- const frontendPath = join3(projectPath, "frontend");
4629
+ const backendPath = join6(projectPath, "backend");
4630
+ const frontendPath = join6(projectPath, "frontend");
1325
4631
  await copyTemplate(
1326
- join3(repoRoot, "templates", backendTpl.folder),
4632
+ join6(repoRoot, "templates", backendTpl.folder),
1327
4633
  backendPath
1328
4634
  );
1329
4635
  await copyTemplate(
1330
- join3(repoRoot, "templates", frontendTpl.folder),
4636
+ join6(repoRoot, "templates", frontendTpl.folder),
1331
4637
  frontendPath
1332
4638
  );
1333
4639
  await substituteTemplate(backendPath, projectName, backendTpl.folder, {
@@ -1399,7 +4705,8 @@ async function runSkillsFlow(opts = {}) {
1399
4705
  console.log(chalk.bold("\nSkills disponibles:\n"));
1400
4706
  console.log(chalk.dim(" create-specs \u2014 specs para proyecto nuevo (/create-specs)"));
1401
4707
  console.log(chalk.dim(" create-specs-from-code \u2014 specs desde c\xF3digo existente (/create-specs-from-code)"));
1402
- console.log(chalk.dim(" design/ \u2014 dark-luxury, glassmorphism, minimal-dashboard, minimal-light, neo-brutalist (/design-<estilo>)\n"));
4708
+ console.log(chalk.dim(" create-prompt \u2014 detalla una idea en specs/prompt.md (/create-prompt)"));
4709
+ console.log(chalk.dim(" design/ \u2014 dark-luxury, glassmorphism, minimal-dashboard, minimal-light, neo-brutalist, docs-starship + cat\xE1logo ui-ux-pro-max (/design-<estilo>; el cat\xE1logo requiere Python 3)\n"));
1403
4710
  let bundle;
1404
4711
  if (opts.only) {
1405
4712
  const names = opts.only.split(",").map((s) => s.trim()).filter(Boolean);
@@ -1472,7 +4779,7 @@ program.command("skills").description(
1472
4779
  "Destinos separados por coma: opencode, pi, gentle-shell, agents (o 'all'). Saltea la pregunta"
1473
4780
  ).option(
1474
4781
  "-o, --only <skills>",
1475
- "Solo estas skills, separadas por coma: create-specs, create-specs-from-code, design"
4782
+ "Solo estas skills, separadas por coma: create-specs, create-specs-from-code, create-prompt, design"
1476
4783
  ).option(
1477
4784
  "-r, --remove",
1478
4785
  "Eliminar en vez de instalar (combinar con --global y opcionalmente --only)"
@@ -1486,6 +4793,64 @@ program.command("skills").description(
1486
4793
  process.exit(1);
1487
4794
  }
1488
4795
  });
4796
+ program.command("studio").description(
4797
+ "Abrir en el navegador las skills instaladas (OpenCode, Pi, Gentle Shell) y las specs/ del proyecto"
4798
+ ).option(
4799
+ "--port <puerto>",
4800
+ "Puerto fijo para el servidor local (por defecto lo elige el sistema)"
4801
+ ).option("--no-open", "No abrir el navegador autom\xE1ticamente").action(async (opts) => {
4802
+ const rawPort = opts.port?.trim();
4803
+ const port = rawPort === void 0 ? 0 : rawPort === "" ? NaN : Number(rawPort);
4804
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
4805
+ console.error(chalk.red(`
4806
+ \u2716 Error: puerto inv\xE1lido: ${opts.port}
4807
+ `));
4808
+ process.exit(1);
4809
+ return;
4810
+ }
4811
+ const projectRoot = process.cwd();
4812
+ const home = process.env.HOME;
4813
+ let skillsLine = "no disponible";
4814
+ let specsLine = "no disponible";
4815
+ try {
4816
+ const report = await discoverSkills({ projectRoot, home });
4817
+ skillsLine = `${report.totals.global + report.totals.project} (${report.totals.global} globales \xB7 ${report.totals.project} del proyecto)`;
4818
+ } catch {
4819
+ }
4820
+ try {
4821
+ const specs = await buildSpecsTree(projectRoot);
4822
+ specsLine = specs.found ? `${specs.totals.files} archivos en specs/` : "sin carpeta specs/ (creala con la skill create-specs)";
4823
+ } catch {
4824
+ }
4825
+ try {
4826
+ const studio = await startStudio({
4827
+ port,
4828
+ projectRoot,
4829
+ version: getCliVersion(),
4830
+ open: opts.open !== false
4831
+ });
4832
+ console.log(chalk.bold("\n fwinit studio\n"));
4833
+ console.log(` ${chalk.dim("Proyecto")} ${projectRoot}`);
4834
+ console.log(` ${chalk.dim("Skills")} ${skillsLine}`);
4835
+ console.log(` ${chalk.dim("Specs")} ${specsLine}`);
4836
+ console.log(` ${chalk.dim("URL")} ${chalk.cyan(studio.address.url)}`);
4837
+ console.log(`
4838
+ ${chalk.dim("Ctrl+C para cerrar")}
4839
+ `);
4840
+ const shutdown = () => {
4841
+ void studio.close().finally(() => process.exit(0));
4842
+ };
4843
+ process.once("SIGINT", shutdown);
4844
+ process.once("SIGTERM", shutdown);
4845
+ } catch (error) {
4846
+ console.error(
4847
+ chalk.red(`
4848
+ \u2716 Error: no se pudo abrir el studio: ${error.message}
4849
+ `)
4850
+ );
4851
+ process.exit(1);
4852
+ }
4853
+ });
1489
4854
  program.argument("[template]", "Template a usar (o 'fullstack' para backend + frontend)").argument("[project-name]", "Nombre del proyecto").option("-p, --pm <package-manager>", "Package manager a usar: npm, pnpm o bun").option(
1490
4855
  "-o, --org <domain>",
1491
4856
  "Organizaci\xF3n reverse-domain para el bundle id de Flutter (ej: ar.miempresa)"
@@ -1638,7 +5003,7 @@ program.argument("[template]", "Template a usar (o 'fullstack' para backend + fr
1638
5003
  spinner.succeed("Template descargado");
1639
5004
  } else {
1640
5005
  const { tempDir, templatePath, repoRoot } = await downloadAndExtract(template.folder);
1641
- const codePath = useLayerLayout ? join3(projectPath, layer) : projectPath;
5006
+ const codePath = useLayerLayout ? join6(projectPath, layer) : projectPath;
1642
5007
  spinner.text = "Creando proyecto...";
1643
5008
  await copyTemplate(templatePath, codePath);
1644
5009
  await substituteTemplate(codePath, projectName, template.folder, {