diff --git a/f22ed9-echo-pad/EchoPad/arithmetic-check.json b/f22ed9-echo-pad/EchoPad/arithmetic-check.json new file mode 100644 index 00000000..1725f6a2 --- /dev/null +++ b/f22ed9-echo-pad/EchoPad/arithmetic-check.json @@ -0,0 +1,64 @@ +{ + "data": { + "type": "card", + "attributes": { + "boardName": "Arithmetic check", + "inkJson": "{\"v\":1,\"strokes\":[{\"w\":2.5,\"pts\":[344,119.4,344,125.7,344,132.9,344,144.6,344,163.9,344,179.1,344,206.3,344,219.8,344,231.9,344,244.7,344,263.6,344,268.6,344,274,344,282.7,344,289.6,344,290.8,344,291.9,344,293.4,344,295]},{\"w\":2.5,\"pts\":[396,170.5,395.7,169.2,395.7,166.8,395.7,157.3,395.9,151.7,397,148.1,398.4,144.2,399.9,141.1,401.9,137.6,404,134.6,409.2,129.7,412.4,127.7,420.9,124.5,425.8,123,431.6,121.5,437.9,119.9,444.2,118.4,450.5,117.4,455.1,117.1,462.3,117.1,465.6,117.1,469.4,117.1,474.2,119.4,476.9,124.3,478.6,127.8,481.4,135.2,482.8,139.5,484,143.5,485.2,148.2,486.7,159.9,487.3,166.1,487.6,180.2,487.6,194.1,484.3,212.1,476.1,232.8,471,243.4,466.4,251.8,456.9,265.5,444,283,439,289,435.1,293,431.3,297.2,427.8,300.7,424,303.7,422.8,304.7,421.6,305.6,420.8,306.2,420.1,304.9,420,303.8,420,302.8,420,301.6,420,300.4,420.9,297.6,423.1,295.4,427.5,291.3,434.2,285.7,442.4,279.3,464.5,263.5,477.9,256.1,494.9,248.6,503.8,245.4,511.3,243,517.1,241.4,528.2,239.3,531.5,239.2,534.6,239.1]},{\"w\":2.5,\"pts\":[579.4,202.3,586,202.3,596.1,202.3,605,202.3,628.3,202.3,654.3,202.3,667.7,202.3,681.4,202.3,698.5,202.3,714.7,202.3,721.3,202.3,730.6,202.3,733.6,202.3]},{\"w\":2.5,\"pts\":[659.4,164.6,659.4,166,659.4,169.5,659.4,174.9,659.4,192.9,659.4,216.9,659.4,234.4,659.4,241.4,659.4,253.8,659.4,258.4,659.4,266.3,659.4,268.4]},{\"w\":2.5,\"pts\":[786.8,164,790,164,793.3,164,797.8,164,805.5,164,820.1,164,826.6,163.7,837.1,163.4,842.5,163.4,843.8,163.4,845.2,163.4,844.9,170.9,843.9,178.6,842.6,190.4,840.9,204.7,839.5,216.3,836.3,244.6,832.9,278.1,831.8,293.5,830.2,315.3,829.4,326.6,828.2,337.7,827.9,343.5,827.6,347.6,827.2,352,826.9,354.1]},{\"w\":2.5,\"pts\":[957.2,224.5,961.3,224.5,971.1,224.5,977.9,224.5,988.3,224.5,995.1,224.5,1003.8,224.5,1005.5,224.5,1007.6,224.5,1010.7,224.5]},{\"w\":2.5,\"pts\":[969.6,261,973.5,261,977.9,261,982.9,261,987.4,261,992.2,261,998,261,1003.5,261,1005.8,261,1008.3,261]},{\"w\":2.5,\"pts\":[1115.5,185.8,1118.9,183.4,1127.6,176.8,1131.5,174,1140.8,168.1,1146.2,165.5,1151.4,163.3,1156.4,161.7,1160.9,161.2,1165.3,160.9,1168.4,160.9,1172.4,160.9,1176.1,160.9,1178.2,160.9,1183.1,161.1,1184.5,161.7,1187.6,164.6,1189.1,166.7,1191.7,171.8,1193,174.9,1194.7,179,1196.7,183.3,1198.2,187.7,1199.4,192,1200.2,196.2,1200.8,200.2,1201.3,204.3,1201.6,208.8,1201.6,212.9,1201.6,220.9,1199.7,229.8,1197.3,234.8,1194.3,239.8,1190.9,244.9,1178.3,258.8,1172,264.6,1165,270.5,1158,276.5,1150.8,282.2,1143.9,287.4,1137.8,291.8,1132.1,295.5,1126.8,299.3,1121.9,302.7,1118.4,304.8,1115.7,306.6,1112.5,308.7,1109.5,310.4,1106.9,311.9,1104.8,313.3,1103,314.5,1101.8,315.3,1104.4,314.1,1124.8,309.6,1137.5,306.7,1163,302,1181.5,299.1,1200,296.2,1228.3,292.6,1242.2,290.8,1250.5,289.9,1256.2,289.5,1261.7,288.9,1265.4,288.6,1267.4,288.6,1268.8,288.5]},{\"w\":2.5,\"pts\":[1308.7,156.5,1312.9,324.1,1313.6,326.9]},{\"w\":2.5,\"pts\":[366.8,407.2,368.8,407.2,371.6,407.2,375.8,407.2,381.3,407.2,387.4,407.2,393.5,407.2,400,407.2,410.1,407.2,417.1,407.2,420.1,407.2,424.4,407.2,426,407.2]},{\"w\":2.5,\"pts\":[367,407.2,367,411.1,367,423.1,367,437.7,367,452.7,367,457.9,367,462.3,367,467.1,367,470.2,367,472.4,367,474.4,367,475.7,368.3,474,370.4,472.6,373.4,470.8,377.3,468.8,381.6,466.3,386.3,463.8,396,459.9,399.8,458.5,404.7,457,409.7,455.5,414,454.4,418.3,453.6,423,453.1,427.7,452.7,432.2,452.3,435.7,452.3,438.9,452.3,442.7,452.3,446.1,452.3,449.1,452.6,452.2,453.3,455.1,454.7,457.7,456.1,459.4,457.2,461.1,458.6,463.1,460.7,463.4,461.9,464.7,466.7,465.2,469.7,465.6,474.1,465.7,478.6,465.7,483.8,465.7,488.5,465.7,493.6,465.3,500.1,464.3,504.4,462.7,508.3,460.2,513.4,457.1,518.2,453.6,522.5,449.9,526.4,445.4,530.2,440,533.8,434.3,536.5,421.5,539.6,413.8,541,403.4,542.5,392.2,543.6,381.2,544.6,370.8,545.1,359.7,545.2,349.4,545.3,343,545.3,337.5,545.3,332.3,545.3,328.4,545,324.9,544.7,322.3,544.4,320.2,543.8]},{\"w\":2.5,\"pts\":[547.1,429.7,548.5,432,556.7,442,563,449.1,571.9,458.6,581.2,470,591,481.9,599.7,491.7,619.9,514.1,628.7,522.7,636.2,529.2,642.3,534.6,646.5,538.3,650.7,542.1,653.6,544.2,655.5,545.3,656.5,546,656.6,544.4]},{\"w\":2.5,\"pts\":[647.5,417.4,647.5,418.7,647.5,420.8,647.5,424.4,646.4,431,643.3,443.5,641.1,450.3,637.7,458,634.3,466.2,628.6,478,623.7,486.8,621.3,491.2,617.6,498,612.8,505.1,608.2,511.9,605.3,516.8,602.3,520.3,599.4,524.3,597.3,527.7,595.9,529.4,595.2,530.3,593.5,532.1,593.1,533.2,592.7,534.2]},{\"w\":2.5,\"pts\":[780.1,435.8,782.3,435.8,784.7,435.8,787.7,435.6,791.2,435.3,800.5,434.6,804.4,434.6,808.4,434.5,813.3,434.5,817.7,434.5,820.7,434.5,823.8,434.5,830.2,434.5,832.5,434.7,834.3,435.4,835.5,436.4,836.4,437.4,837.1,438.3,837.4,439.6,837.4,440.8,837.4,444.2,837.4,446.4,837.4,449.4,837.1,452.4,833.9,460.1,831.3,463.7,824.9,470.7,822.1,473,819.8,474.8,816.4,477,812.9,479,809.6,480.7,806.7,482,804.2,483,802.5,483.6,801.5,484.1,800,484.3,801.3,484.3,803.3,484.3,805.8,484.3,809.3,484.6,813.7,485.2,819.3,486.7,825.4,488.9,837.4,493.3,842.6,495.7,852.2,500.5,855.9,503,859.3,505.7,861.7,508,863.2,509.7,864.1,511.7,864.6,513.9,864.9,515.6,864.9,517.7,864.4,520.6,862.5,523.5,859.5,526.3,856,528.7,852,530.9,847.3,532.8,841.8,534.3,831.3,536.7,826.5,537.3,821.3,538,816.6,538.7,805.2,539.3,800.6,539.3,787.6,539.3]},{\"w\":2.5,\"pts\":[940,465.9,946.7,465.9,953.3,465.9,960.1,465.9,969.6,465.9,986.1,465.9,996.1,465.9,1017.7,465.9,1020.8,465.9,1022.6,465.9]},{\"w\":2.5,\"pts\":[958.7,499.2,960.6,499.2,963.6,499.2,967.3,499.2,974,499.2,982.3,499.2,991.1,499.2,1043.3,496.7,1049,496.7]},{\"w\":2.5,\"pts\":[447.1,701.1,448.3,700.6,449.4,700.8,450.2,702.1,450.7,703.2,451.3,704.4,451.9,705.6,452.5,706.8,453.1,707.9,454,709.7,454.6,711.1,454.9,712.6,455.3,714.6,455.7,717.2,455.8,723.1,455.8,726.3,455.8,729.5,455.8,732.2,455.8,734.1,455.8,735.9,455.8,738.2,455.8,742,455.8,744.4,455.8,746.4,455.4,748.8,454.5,751.1,451.3,756.2,449.9,758.2,446.2,763,444.7,764.8,443.3,766.5,442.1,767.7,441.2,768.7,440.3,769.5,438.8,770.7,436.7,772.1,435.2,773.2,433.7,774.4,432.5,775.3,431.2,776.3,430.2,777.2,429.1,778,431.2,776,434.6,773.4,439.1,769.6,445,764.7,450.7,759.8,456.6,754.2,464.2,747.2,471.9,740.5,477.5,735.3,482.1,731.3,487.4,726.8,492.4,722.7,496,719.5,498.5,717,502.8,713.2,503.8,711.9,505,710.9,506.6,709.5,507.7,708.4,508.6,707.5,509.5,706.6,510.6,705.5,511.7,704.7,512.4,704,510.8,706,509.5,707.9,508.1,710.3,506.7,713.5,504,722.3,503.5,724.8,502,733.8,502,737.9,502,742.6,502,750.9,502,754.8,503.2,758.1,505.9,761.9,508.5,764.3,512.2,767,516.6,769.9,521.5,772.5,527,775.2,532.6,777.4,537.4,778.8,543.3,779.7,552.6,780.9,556.3,780.9,561.1,780.9,569,779.8,570.7,778.9,572.3,777.2,573.1,774.5]},{\"w\":2.5,\"pts\":[613.2,669.2,615,669.6,616.9,669.6,621.1,669.6,623,669.6,624.5,669.6,626.4,669.6,628,669.6,630,669.6,631.7,669.6,632.7,669.6,634.5,669.9,635.2,670.8,635.6,671.8,635.6,673.3,635.6,674.4,635.6,675.5,635.6,677,635.6,678.1,635.6,679.6,635.3,681.4,634.7,683.4,633.8,685.4,632,687.9,629.9,690.5,623.7,695.5,618.1,699.1,615.3,701,612,702.9,608.9,704.8,606.3,706.4,603.8,707.5,601.4,708.5,599.9,709.1,599,709.6,597.2,709.9,598.6,709.9,602.2,709.9,606.9,709.6,612.5,708.3,619.9,707.5,628.4,707,638.6,705.5,652.2,704,656.4,703.6,660.6,703.3,663.3,703.2,665.4,703.2,666.7,703.2]},{\"w\":2.5,\"pts\":[712,713.5,714.5,713.5,717.5,713.5,721.1,713.5,725.5,713.5,730.4,713.5,734.8,713.5,740.8,713.5,743,713.5,749.4,713.5,751.4,713.5,755.4,713.5,756.7,713.5]},{\"w\":2.5,\"pts\":[711.1,737.8,713.6,737.8,716,737.8,718.6,737.8,720.7,737.8,723.4,737.8,727,737.8,732.9,737.8,736.2,737.8,739.1,737.8,741.2,737.8,742.8,737.8,744.7,737.8,746.2,737.8,747.3,737.8,748.3,737.8,749.6,737.8,750.7,737.8,751.8,737.8]},{\"w\":2.5,\"pts\":[858.7,678,859.2,681.7,859.2,686.2,859.2,692.6,858.4,710.2,856.5,719.4,854.8,730,853.8,738.7,852.6,744.8,851.6,748.8,850.6,754.1,849,760.2,847.7,765.5,846.8,769.9,845.9,775.4,845.5,777.4,845.5,779,845.5,780.1,845.5,781.2,845.5,783.3,845.5,785.9,845.5,786.9]},{\"w\":2.5,\"pts\":[972.8,677.2,971.9,676.7,969.2,676.7,965.2,676.7,962.8,676.7,960,676.7,954.8,676.7,950.6,676.7,948.3,677.7,946.6,678.6,945.3,679.8,943.6,681.4,941.6,683.4,939.7,685.5,938.3,687.2,936.9,688.9,935.6,690.8,934.4,692.4,932.2,695.4,931.4,696.6,929,700.6,928.1,702.7,926,706.9,925.3,709.2,924.6,711.5,923.7,713.8,923,716.1,922.8,717.9,922.2,719.8,921.8,721.8,921.5,723.7,921.1,725.7,920.8,728.2,920.4,730.6,920.4,732.6,920.3,736.8,920.3,740.8,920.3,742.5,920.3,744.2,920.3,745.9,920.3,747.2,920.3,748.4,920.3,749.6,920.3,751.5,920.3,752.8,920.6,754.5,921.1,755.4,921.6,756.3,922.4,756.9,923.3,757.5,926.2,759.1,927.1,759.5,928.5,759.8,931.8,760.8,935.5,761.5,937.9,762.1,940.2,762.5,942.5,762.8,944.4,763.1,946.2,763.1,948.5,763.4,950.5,763.6,952.6,763.7,956.2,763.7,957.9,763.7,959.4,763.7,960.5,763.7,962.4,763.7,963.8,763,965.1,761.1,966,759.9,966.8,758.7,967.4,757.5,968,756.4,968.5,755.5,968.9,754.6,969.8,752.2,970.2,750.7,970.5,748.8,970.6,746.9,970.9,744.4,971.2,742.1,971.2,736.4,971.2,734.5,971.2,732.6,971.2,731.1,971.2,729.2,971.2,727.5,970.5,726.1,969.3,725.5,967.7,725.1,966.5,725,965.3,725,963.7,725,961.8,724.7,958.2,724.4,955.5,724.4,952.3,724.4,950,724.4,947.7,724.4,941.8,724.4,940.5,724.4,939.4,724.4,938,724.4,937,724.4,936,724.4,934.9,724.4,932.8,724.4,930.2,724.4,927.9,724.9,927,725.4,926,725.4,925,725.8,924,726.3]}]}", + "viewportJson": "{\"x\":168,\"y\":9,\"zoom\":0.61}", + "echoes": [ + { + "label": "Echo № 1", + "mode": "auto", + "content": "12 + 7 = 19, not 21 ✗ → 19", + "x": 1366, + "y": 147, + "width": 340, + "acceptedAt": "2026-07-29T14:51:24.956Z", + "sketchJson": null, + "sceneJson": null + }, + { + "label": "Echo № 2", + "mode": "auto", + "content": "5 × 3 = 15", + "x": 1207, + "y": 436, + "width": 340, + "acceptedAt": "2026-07-29T14:51:52.151Z", + "sketchJson": "{\"polylines\":[],\"labels\":[{\"text\":\"15\",\"x\":1081.986689539992,\"y\":454.1526972433873,\"size\":44.954856762614234}]}", + "sceneJson": null + }, + { + "label": "Echo № 3", + "mode": "auto", + "content": "x² = 16 → x = ±√16 = ±4\n(don't forget the negative root!)", + "x": 1093, + "y": 689, + "width": 340, + "acceptedAt": "2026-07-29T14:52:54.518Z", + "sketchJson": null, + "sceneJson": null + } + ], + "cardInfo": { + "name": "Arithmetic check", + "summary": "Handwritten sum with a wrong answer — the marker checks it", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../echo-pad/echo-pad", + "name": "EchoPad" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/EchoPad/diagram-complete.json b/f22ed9-echo-pad/EchoPad/diagram-complete.json new file mode 100644 index 00000000..5b27628e --- /dev/null +++ b/f22ed9-echo-pad/EchoPad/diagram-complete.json @@ -0,0 +1,42 @@ +{ + "data": { + "type": "card", + "attributes": { + "boardName": "Diagram complete", + "inkJson": "{\"v\":1,\"strokes\":[{\"w\":2.5,\"pts\":[457,227.4,460.6,227.4,469.2,227.4,497.7,227.4,517.7,227.4,541.4,227.4,565.7,227.4,584.6,227.4,604,227.4,627.1,227.4,646.7,227.4,665.4,227.4,677.7,227.4,694.4,227.4,698.8,227.4,704.7,227.4,709.7,227.4,713.7,227.4,716.5,227.4,718.4,227.4,719.6,227.4,720.7,227.4,721.1,228.4,720.4,229.3,719.3,230.9,718.8,232,718.4,233.7,717.8,242.1,717.6,247.1,717.5,254,717.4,261.5,717.4,269.1,717.4,285,717.4,291.9,717.4,298.2,717.4,304.5,717.4,310.1,717.4,315.2,717.4,320.3,717.4,325.3,717.2,329.1,717,332.4,716.9,335.7,716.9,339,716.9,342.8,716.9,346.2,716.9,349.2,716.9,352,716.9,354.2,716.8,356.2,716.8,358.3,716.8,360,716.8,361.7,716.8,363.6,716.8,365.8,716.8,368,716.8,370.1,716.8,374,716.8,375.7,716.8,377.8,716.8,379.7,716.8,381.7,716.8,383.9,717,386.7,717.3,389.1,717.3,391.2,717.5,393.8,717.7,396.3,717.9,398.9,718.1,401.2,718.2,403.8,718.4,406,718.6,407.9,718.8,409.8,719,411.4,719,413,719.1,414.2,719.2,415.5,719.3,416.9,719.5,418.1,719.6,419.2,719.7,420.4,720,422.1,720,423.4,720.3,424.5,720.4,425.5,720.4,427.1,720.4,428.3,720.4,429.4,720.4,430.5,720.4,431.8,720.4,433,720.4,434.3,718.4,434.4,716.5,434.4,713.7,434.4,709.8,434.4,703.6,434.7,696.1,435.7,688.1,436.7,678.5,437.7,667.7,439.2,658.7,440.4,649.6,441.4,637.8,442.6,628.1,443.7,618.8,444.4,608,445.3,597.8,446,588.6,446.8,580.9,447.2,574.4,447.2,569.7,447.3,565.7,447.5,561.2,447.8,557.2,447.8,553.7,448.1,550.9,448.3,545.4,448.4,543.4,448.6,541,448.7,538.8,448.7,536.8,448.7,534.8,448.7,532.8,448.7,531.1,448.7,529.1,448.7,526.3,448.7,523.2,448.7,520.2,448.7,517,448.7,514,448.7,511.1,448.7,507.9,448.7,505.1,448.7,502.6,448.7,500.2,448.7,498.3,448.7,496.8,448.7,495.1,448.7,493.4,448.7,491.5,448.7,489.6,448.7,488,448.7,486.6,448.7,485.1,448.7,483.9,448.7,482,448.7,480.8,448.7,479,448.7,477.9,448.7,475.7,448.7,474.5,448.7,473.2,448.7,471.8,448.7,470.8,448.7,469.5,448.7,468.3,448.7,466.9,448.6,465.2,448.1,463.9,447.9,462.5,447.7,461.1,447.5,459.7,447.3,458.6,447.2,457.6,447,456.3,446.7,455.1,446.4,455.1,445.3,455.1,444,455.1,442.7,455.1,441.7,455.1,440.1,455.1,437.8,455.1,434.2,455.1,426,455.1,417.9,455.1,413.4,455.1,407.9,455.1,401,455.1,394.3,455.1,386.9,455.5,376.9,456.1,367.7,456.7,360.3,457.3,352.4,458,345.1,458.6,337.8,459.3,331.3,459.9,325.5,460.5,319.4,461.1,313.9,461.6,308.3,462.7,297.9,463.1,293.9,463.7,289,464.2,285,464.7,281.4,465.2,277.4,465.8,273.1,466.1,269.5,466.2,266.6,466.4,262.7,466.7,259.1,466.7,255.7,466.7,252.4,466.7,249.8,466.7,246.8,466.7,243.7,466.7,241.2,466.7,238.7,466.7,236.5,466.7,235.1,466.7,233.6,466.7,232.2,466.7,230.9,466.7,229.9,466.7,228.3,466.7,227.2]},{\"w\":2.5,\"pts\":[553.3,436.2,553.3,434.5,553.3,432.9,553.3,430.3,553.3,426.8,553.3,423.3,553.3,417.6,553.1,412.5,552.1,404.4,551.9,401.9,551.6,398.4,551,391.6,550.8,389.3,550.8,386.7,550.7,384.1,550.7,382.1,550.7,380.4,550.7,378.7,550.7,377.3,550.7,375.5,550.7,374.4,550.7,373.3,550.7,372,550.7,370.9,550.7,369.8,550.8,368.7,552,368.4,553.2,368.1,554.4,367.6,556.1,367.3,557.2,367.1,558.3,366.9,559.4,366.6,562.9,366.1,565.3,365.7,567.8,365.2,570.6,364.7,573.3,364.5,575.9,364.2,578.8,364,581.3,363.7,583.8,363.3,586.6,362.8,591.6,361.5,593.8,361.1,596.3,360.7,598.8,360.1,601.7,359.4,604.5,358.6,607.4,357.9,611,357.4,614,356.8,616.8,356.4,620,355.9,622.6,355.4,625.2,355.1,627.5,354.9,629.2,354.7,630.5,354.7,631.6,354.5,632.9,354.3,634,354.5,634,355.5,633.7,356.5,633.3,357.6,633,358.9,632.8,360,632.7,362.3,632.7,363.9,632.7,365.8,632.7,367.5,632.7,369.5,632.9,371.7,633.5,373.9,634.5,376.8,635.2,379.4,635.6,381.4,636.1,383.9,636.5,386.4,637,388.8,637.6,391.3,638.2,393,638.5,394.7,638.9,396.8,639.4,398.9,639.6,400.9,639.9,402.7,640.1,404.4,640.3,406.2,640.5,407.6,640.6,409,640.6,410.4,640.8,411.9,641,413.3,641,414.5,641,415.9,641,417.2,641,418.3,641,420.3,641,422.1,641,423.7,641,425.3,641,426.8,641,428.6,641,430,641,431.4,641,432.7,641,433.8,641,435.1,641,436.3,641,437.5]},{\"w\":2.5,\"pts\":[525.2,73.9,525.2,72.3,525.2,70.4,526.8,68,528.3,66.6,529.9,66,532.4,65.3,535.2,64.6,538.3,63.9,540.5,63.7,542.2,63.7,545.1,63.5,548.1,63.2,551.2,63.2,554.5,63.2,557.4,63.1,560.3,63.1,563,63.3,565.3,63.9,567.7,64.8,569.7,65.9,571.5,67,573,68,574.1,69,575,69.9,576.2,71.3,576.8,72.5,577.3,73.8,577.4,75.2,577.4,76.6,577.4,78.2,577.4,79.3,576.6,81.2,576,82.1,575.3,83,574.3,84.1,573.1,85.6,571.6,87.3,569,89.9,567.4,91.6,566.1,93.1,564.5,94.7,563.2,96.2,561.9,97.7,560.8,98.8,560.1,99.6,559.1,100.9,558.4,101.9,558.2,103.3,557.9,104.5,557.8,105.6,557.6,106.6,557.6,107.8,557.6,109.5,557.4,110.8]},{\"w\":2.5,\"pts\":[557.4,161.5,558.8,160.6,559.5,159.7,560.1,158.8,560.7,158,561.3,157.1,561.9,156,562.8,155.1]}]}", + "viewportJson": "{\"x\":149,\"y\":17,\"zoom\":0.85}", + "echoes": [ + { + "label": "Echo № 1", + "mode": "auto", + "content": "This is a house outline missing its roof — rectangle walls + a door, but no triangle on top.\nCompleting it: add a peaked roof connecting the top corners.", + "x": 751, + "y": 63, + "width": 340, + "acceptedAt": "2026-08-05T10:12:22.738Z", + "sketchJson": "{\"polylines\":[[457.1309719411182,224.1715257593184,587.100538595693,165.16297413048423,718.0698711476108,224.1715257593184]],\"labels\":[{\"text\":\"roof\",\"x\":572.1040501355499,\"y\":185.16587298771617,\"size\":12.001739314339153}]}", + "sceneJson": null + } + ], + "cardInfo": { + "name": "Diagram complete", + "summary": "An unfinished figure the marker completes in red ink", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../echo-pad/echo-pad", + "name": "EchoPad" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/EchoPad/wave-study.json b/f22ed9-echo-pad/EchoPad/wave-study.json new file mode 100644 index 00000000..842719e7 --- /dev/null +++ b/f22ed9-echo-pad/EchoPad/wave-study.json @@ -0,0 +1,42 @@ +{ + "data": { + "type": "card", + "attributes": { + "boardName": "Wave study", + "inkJson": "{\"v\":1,\"strokes\":[{\"w\":2.5,\"pts\":[419.2,32.6,418.8,33.6,418.8,37.6,418.8,41.9,418.8,48.2,418.8,57.5,418.8,68.6,418.8,88.5,418.8,156.6,418.8,183.3,418.8,212.7,418.8,243,418.8,266.2,418.8,288.4,418.8,310.7,418.8,372.6,418.8,393.9,418.8,419.4,418.8,442.9,418.8,463.8,418.8,493.6,419.1,500.2,421.4,521.2,421.8,524.9,422.6,529.7,423.4,533.8,423.9,536.5,424.4,538.4,424.6,539.6,425.2,542.1]},{\"w\":2.5,\"pts\":[291.6,277.3,294.5,277.3,300.2,277.3,312.6,277.3,349,277.3,366.5,277.3,420.9,277.3,444.9,277.3,502.5,277.3,536.9,277.3,572.3,277.3,624.8,277.3,673.4,277.3,712.3,277.3,751.8,277.3,790.4,277.3,836.8,277.3,871,277.3,899.6,277.3,932.1,277.3,957.7,277.3,972.1,277.3,982.7,277.3,994.4,276.9,1001.5,276.3,1006.3,275.7,1011.6,275.1,1017.3,274.2,1018.7,273.8,1019.7,273.4,1021.2,272.9]},{\"w\":2.5,\"pts\":[423.4,274,423.4,270.3,423.4,267.5,423.4,265.6,423.4,263,423.4,256.5,423.4,251.6,423.4,245.3,423.4,238.6,423.4,235.2,423.7,231.7,424.8,226.1,426.1,222.9,427.5,220,429.2,217.3,434.4,210,437,206.8,442.8,200.1,445.7,197.2,448.3,194.8,451,192.7,459.8,186.6,465.1,183.1,485.2,171.7,491.4,168.1,503.4,161.8,509.2,159.1,514.9,156.7,520.1,154.4,534.9,149.4,540.2,147.8,547.7,145.9,550.7,145.2,553,144.8,555.9,144.3,560.3,143.9,561.8,143.9,562.9,143.9,564.6,143.9,567.1,146.3,568.4,148,570.4,150.2,577.7,160.7,580.9,165.3,584.4,171.1,590,181.3,592.2,186,595,191.5,600.4,202.8,602.9,208.8,605.5,214.9,610.5,226.1,612.8,231.4,614.9,237.1,619.6,250.3,622.5,256.6,627.9,269.1,630.9,274.8,637.8,287,641.2,292.7,644.6,297.9,647.5,301.8,650.6,305.4,654.5,310,662.9,319.2,672.5,328,681,335.8,684.9,338.9,689.1,341.7,693.3,344.6,701.2,349.7,709.1,353.7,713,355.2,717.2,356.9,720.7,358.3,723.6,359.2,728.5,360.2,731.2,360.7,733.6,361.2,737.9,361.5,744.1,361.5,750.5,359.5,752.1,358.6,755.8,356.2,759.5,353,761,351.5,762.9,349.7,764.7,347.9,766.3,346.3,768.1,344.3,769.7,342.3,771.5,340.1,774.8,335.4,776.5,333,780.3,325.8,781.7,322.9,784.1,316.8,785.2,313.8,786.2,311.2,788.2,305.1,789.7,298.8,790.7,295.4,796,267.4,798.4,253.3,799.1,250.4,799.8,247.6,800.7,243.4,802.1,238.1,802.8,234.9,803.9,230.8,808.5,219.3,811.7,213.4,816.3,206.2,821.1,198.8,823.8,195.5,826.4,192.6,829.3,189.6,831.6,187.1,837.8,181.5,839.8,179.7,846.6,173.4,848.9,171.1,851.1,169.2,853.2,167.5,854.6,166.3,856,164.9,857.8,163.3,859.7,161.8,861.8,160.5,863.9,159.3,865.8,158.3,867.6,157.6,869.3,157,870.9,156.8,872.8,156.8,875.1,156.8,877.3,156.8,879.8,156.8,884.7,156.8,888.2,156.8,892,157.2,895.3,158.2,898.6,159.2,900.5,159.9,902.3,160.8,904.7,162.1,907.3,163.5,910,165,912.3,166.4,914.3,168.1,916.2,170,918.4,172.2,920.7,174.7,922.5,176.8,924.4,178.8,926.6,181.6,930.4,185.9,936.1,192.3,938.4,195.4,940.8,198.6,943.1,201.7,944.9,204.1,946.1,206,947.6,208.5,949.3,211.2,951.1,214,952.9,216.8,954.4,219.3,955.7,221.7,956.8,224,957.9,226,959.6,229.3,960.3,230.6,961.8,233.7,963,236.3,963.9,237.8,964.7,239.3,965.5,240.6,966.1,241.7,966.7,242.8,967.5,244.2,968.3,245.7,969.2,247.1,970,248.6,971.1,250.2,972.4,251.8,973.5,253.3,974.6,254.8,975.6,256,976.3,257.1,977.1,258.2,977.8,259.4,978.8,260.6,980.5,262.3,981.2,263.2,981.9,264,983.1,265.8,984.7,267.4,986,268.6,987.2,268.9,988.4,268.9,989.2,269.6,989.2,270.8,989.6,272.2,990.8,272.6,992,273.1,993.3,273.1,993.8,274.2]}]}", + "viewportJson": "{\"x\":202,\"y\":36,\"zoom\":0.83}", + "echoes": [ + { + "label": "Echo № 1", + "mode": "animate", + "content": "Rough hand-drawn wave morphs into a clean sine curve, then ripples with growing amplitude.", + "x": 1051, + "y": 33, + "width": 340, + "acceptedAt": "2026-07-29T14:59:41.615Z", + "sketchJson": null, + "sceneJson": "{\"title\":\"Wave study\",\"steps\":[{\"add\":\"ink\",\"id\":\"ink1\"},{\"add\":\"axes\",\"id\":\"ax\",\"xRange\":[-6,6],\"yRange\":[-2,2]},{\"add\":\"graph\",\"id\":\"g\",\"fn\":\"sin(x)\",\"xRange\":[-5,5],\"axes\":\"ax\"},{\"play\":\"create\",\"duration\":1,\"target\":\"ink1\"},{\"play\":\"transform\",\"duration\":2,\"target\":\"ink1\",\"to\":\"g\"},{\"play\":\"fadeOut\",\"duration\":0.5,\"target\":\"ink1\"},{\"play\":\"create\",\"duration\":1,\"target\":\"g\"},{\"play\":\"wave\",\"duration\":3,\"target\":\"g\",\"amplitude\":0.6}],\"background\":\"paper\",\"ink\":[[-2.792965863750554,3,-2.7970666469632035,2.987808482340771,-2.7970666469632035,2.9410373873208218,-2.7970666469632035,2.8895559332052607,-2.7970666469632035,2.815852667356288,-2.7970666469632035,2.7062398404019508,-2.7970666469632035,2.5756797694694846,-2.7970666469632035,2.3417688783803756,-2.7970666469632035,1.5398440963499336,-2.7970666469632035,1.2249704448056744,-2.7970666469632035,0.8792300871878234,-2.7970666469632035,0.5222402837298654,-2.7970666469632035,0.2490948721737843,-2.7970666469632035,-0.01269026156346961,-2.7970666469632035,-0.2752512191517662,-2.7970666469632035,-1.003749815280036,-2.7970666469632035,-1.2551721590069456,-2.7970666469632035,-1.554917245455889,-2.7970666469632035,-1.8311659524161374,-2.7970666469632035,-2.0778779370474365,-2.7970666469632035,-2.42904906162258,-2.794185015516477,-2.506077286833161,-2.7671420127087334,-2.753343431358061,-2.7616558297620806,-2.7973991428993643,-2.7526784394857393,-2.853424708142456,-2.7430914733264373,-2.90224619476873,-2.7369402985074625,-2.934110388650805,-2.73162036352889,-2.9560551204374166,-2.7291266440076845,-2.97013078173489,-2.722033397369587,-3],[-4.29545958327176,0.11825772129451757,-4.260769173932319,0.11825772129451757,-4.19343874685976,0.11825772129451757,-4.04824885473622,0.11825772129451757,-3.6187749371952123,0.11825772129451757,-3.41351411260529,0.11825772129451757,-2.7727390276341066,0.11825772129451757,-2.489951233929363,0.11825772129451757,-1.81132702822521,0.11825772129451757,-1.407122801832422,0.11825772129451757,-0.9892308260676813,0.11825772129451757,-0.37173045662775167,0.11825772129451757,0.2002179695581506,0.11825772129451757,0.6584527855770649,0.11825772129451757,1.1243904241170393,0.11825772129451757,1.5788569528594654,0.11825772129451757,2.1250923599822675,0.11825772129451757,2.5269136988325704,0.11825772129451757,2.8641754100783214,0.11825772129451757,3.2472107285355403,0.11825772129451757,3.547953302792967,0.11825772129451757,3.7176924782030443,0.11825772129451757,3.842489286242057,0.11825772129451757,3.980863011674302,0.12269100044332798,4.063543667799617,0.13011674301758527,4.12067755282991,0.13676666174080054,4.1824109649770955,0.14363824442145678,4.250240135953894,0.15461061031476267,4.266310772868332,0.15921013743165346,4.278280626570121,0.16380966454854426,4.295459583271761,0.17001625535687898],[-2.7430360573370764,0.15660558593172727,-2.7430360573370764,0.20066129747303052,-2.7430360573370764,0.2335229791635876,-2.7430360573370764,0.2564651987586817,-2.7430360573370764,0.2862235850450714,-2.7430360573370764,0.362974730308852,-2.7430360573370764,0.4204965272646666,-2.7430360573370764,0.495585192847643,-2.7430360573370764,0.5739434018028668,-2.7430360573370764,0.614563322003842,-2.7400435939116297,0.6555711541303385,-2.726245012560957,0.7211282695433723,-2.711615191369883,0.7591436382444215,-2.694768730604404,0.7933353036796217,-2.6748743904241166,0.8245345056893749,-2.612974730308852,0.9108726171124575,-2.583050096054381,0.9480567459731049,-2.5147775971627016,1.0267474508644894,-2.4802534357913406,1.0613824442145705,-2.4503288015368696,1.0895337668095166,-2.417965863750554,1.1144709620215754,-2.3148921235407123,1.1860130042855033,-2.251607063691444,1.2274087483375202,-2.01575661297473,1.3612937786315944,-1.942275011083198,1.403797842470814,-1.801185902172307,1.4780552682133887,-1.732691739323186,1.5105290379784246,-1.6659708881335897,1.5386249445840106,-1.6046253879119252,1.565945027338555,-1.4297879414807153,1.6245197280922123,-1.3677220333973694,1.6429178365597754,-1.2791118664105212,1.6655829762080687,-1.2436456332200378,1.6736737106546475,-1.2168242943697358,1.6781624057928182,-1.1829651248706965,1.6838148367075514,-1.1304861829466528,1.6884697798138022,-1.1135288902024525,1.6889131077286834,-1.1006723806709027,1.6890239397074036,-1.080057632628934,1.689134771686124,-1.051130486182946,1.6613159450273387,-1.0354477611940303,1.6403687010492094,-1.0125609575882961,1.6148773459435495,-0.9261120141864934,1.4908563617555783,-0.8888724693364851,1.436548692182651,-0.8468117334121464,1.3682761932909708,-0.7813654499778332,1.2484114083050097,-0.7553199349785724,1.1937158268065613,-0.7225690852667351,1.128047879414807,-0.6594502733855472,0.9957144968228167,-0.6289714792374753,0.9248374464312102,-0.5984926850894049,0.8529629082311216,-0.5400842323038275,0.7210728535540121,-0.5123208216344017,0.6585636175557854,-0.4878823703265849,0.5918427663661889,-0.4322447170090148,0.436013004285503,-0.39882887542485596,0.3619218265110091,-0.3350450716713465,0.21429363085562253,-0.30002216639574375,0.14768361164474653,-0.2187269100044336,0.0038237032658487873,-0.1780515738140979,-0.06295256391310758,-0.13842914142160437,-0.12424264814541133,-0.10407122801832452,-0.17007167134623888,-0.0675520910299977,-0.21268656716417927,-0.02150140387172986,-0.2671050687158268,0.07669572927442073,-0.37450125609575896,0.18968893157972597,-0.47823998817792207,0.28960396039604025,-0.5710063543667799,0.335931727501109,-0.6074700753657455,0.3856398699571452,-0.6405534210137436,0.43534801241318144,-0.6736367666617409,0.5284468745382008,-0.7345943549578839,0.620991576769617,-0.7813100339884733,0.6663772720555633,-0.7994310625092362,0.7169166543520021,-0.8194916506576032,0.7570378306487362,-0.8348972956997196,0.7911740800945763,-0.8461467415398256,0.8495825328801537,-0.8583936751884147,0.880947982857988,-0.8639352741244272,0.9094872173784538,-0.8694214570710804,0.9601928476429737,-0.8734668242943695,1.032843209694104,-0.8736884882518104,1.1079318752770808,-0.850025860795035,1.127438303531846,-0.8388872469336487,1.1709952711689076,-0.8107913403280628,1.214940150731491,-0.7733301315206149,1.232562435348013,-0.7556524309147332,1.2541192552091025,-0.7340401950642829,1.275121915176592,-0.7129821191074329,1.2944066794739177,-0.693641938820748,1.315575587409488,-0.6702563913107727,1.334306191813212,-0.6472587557263187,1.3553088517807002,-0.6211578247376981,1.394432540268953,-0.5663514112605292,1.4149364563322007,-0.5371471848677406,1.4591029998522238,-0.4526378010935425,1.4760048766070641,-0.4187232156051426,1.504710359095611,-0.3469040933944142,1.5176222846165222,-0.3120474360868925,1.5284838185311076,-0.28123614600265995,1.5519247820304423,-0.20853036796216973,1.5698795625831248,-0.134937934091917,1.5818494162849124,-0.09531550169942352,1.6446357322299394,0.23496379488695113,1.672731638835526,0.40038052312693967,1.6808777892714641,0.4351817644451011,1.6895226836116453,0.46754470223141703,1.699774641643269,0.5169203487512929,1.7160115265257867,0.5797620806856806,1.7245455888872467,0.6172232894931284,1.737235850450717,0.6660447761194029,1.7909339441406824,0.800705630264519,1.8285614009162112,0.8709176887838036,1.8827582385104185,0.9559258164622431,1.9393933796364708,1.0431505837150878,1.971091325550466,1.081941776267179,2.0018471996453373,1.116022609723659,2.0361496970592587,1.1503805231269395,2.0632481158563616,1.1801389094133292,2.1368405497266143,1.2466380966454855,2.16028151322595,1.2670865967193734,2.240191369883257,1.341898182355549,2.2674560366484413,1.3688857691739322,2.2927811437860206,1.391384660854145,2.3173304270725588,1.4105585931727502,2.334066055859318,1.4247450864489433,2.351078764592877,1.4418132111718633,2.372413920496527,1.4610979754691886,2.394247820304419,1.4783877641495493,2.4189633515590367,1.4933500812767844,2.4438451307817353,1.5078136544997784,2.466066942515147,1.5193401802866853,2.487069602482636,1.5276525786907047,2.5068531106842036,1.5351337372543226,2.526193290970888,1.5375720407861682,2.548969262597902,1.5375720407861682,2.5751256095758825,1.5375720407861682,2.6012819565538647,1.5375720407861682,2.631262006797695,1.5375720407861682,2.6887838037535094,1.5375720407861682,2.730068715826807,1.5375720407861682,2.774734003251072,1.5320304418501551,2.813968523718043,1.520393084084528,2.852260972365894,1.5087003103295404,2.874870696024827,1.5002216639574404,2.895984187971036,1.4896926259790157,2.9246342544702233,1.4743423969262597,2.955113048618295,1.4579392640756614,2.98686641052165,1.440704891384661,3.0133552534357912,1.4237475986404609,3.036962464903207,1.404185754396335,3.05973843653022,1.3820747746416433,3.086061031476283,1.3558630116743018,3.1124390424117045,1.326658785281513,3.134162110240875,1.3021095019949755,3.155885178070046,1.2775602187084378,3.1822077730161076,1.2454189448795625,3.2269838924190934,1.1950458105512043,3.293593911629969,1.118627161223585,3.320692330427073,1.0824959361607804,3.3488990690113796,1.0447022314171714,3.3759974878084824,1.0085710063543667,3.398053051573814,0.9799763558445396,3.4117962169351266,0.9574774641643267,3.429861829466529,0.9282732377715384,3.449811585636176,0.8967969558149846,3.4708142456036657,0.8638244421457071,3.4919831535392345,0.8305748485296289,3.509660854145116,0.8014260381262005,3.5242906753361902,0.7728868036057337,3.5380892566868622,0.7455113048618296,3.5510011822077736,0.722292005319935,3.570285946505098,0.6836116447465642,3.5782658489729577,0.6676518398108469,3.596054381557559,0.6315760307374019,3.610795034727354,0.6010418205999705,3.6208807447908966,0.5834749519728093,3.63057854292892,0.566295995271169,3.6398884291414215,0.5498374464312101,3.6470925077582383,0.5375905127826215,3.6544628343431365,0.5245123392936308,3.6638835525343576,0.507610462538791,3.6734151027043,0.4907640017733116,3.6832237328210433,0.47341879710359097,3.6932540268952274,0.4557965124870695,3.7061659524161374,0.4372321560514259,3.7219040933944143,0.4183353036796216,3.7347051869366044,0.40038052312693967,3.7470075365745528,0.38320156642529907,3.758700310329541,0.3692921530959065,3.7675668686271617,0.3559923156494753,3.7764334269247826,0.34269247820304405,3.7852999852224034,0.32939264075661284,3.796992758977391,0.31548322742721996,3.816166691295996,0.2943697354810108,3.8244236737106547,0.28389611349194604,3.833123984040196,0.27491872321560473,3.8478646372099896,0.2532510713757945,3.865653169794592,0.23463129895079038,3.8811696468154286,0.22061105364267772,3.8951344761341806,0.2169535983449093]]}" + } + ], + "cardInfo": { + "name": "Wave study", + "summary": "A rough wave sketch answered with a composed manim scene", + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../echo-pad/echo-pad", + "name": "EchoPad" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/CardListing/f4661be9-ff9e-4917-a307-e264d42c4326.json b/f22ed9-echo-pad/echo-pad/CardListing/f4661be9-ff9e-4917-a307-e264d42c4326.json new file mode 100644 index 00000000..06916fe8 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/CardListing/f4661be9-ff9e-4917-a307-e264d42c4326.json @@ -0,0 +1,153 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Echo Pad", + "summary": "Echo Pad is a whiteboard that answers back: write math, sketch diagrams, or scribble half-formed ideas in blue ink, and an AI marker replies in red pen directly on the paper — checking your work, writing the missing answer into the blank, completing your drawing, or playing a short manim animation of the idea inside a projection slip. Circle any region and pick Solve, Check, Complete, Explain or Animate, or leave the ambient Auto mode on and the marker follows along as you write, proposing help only when your pen pauses. Every AI response is a draft you accept or dismiss, so the board stays yours: handwriting recognition, math checking and grading, sketch completion, and hand-drawn-curve-to-animation morphing on an infinite zoomable canvas.", + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "cardInfo.cardThumbnail": { + "links": { + "self": "../ListingThumbnails/echo-pad-1c0f5c.png" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + }, + "categories.0": { + "links": { + "self": "https://app.boxel.ai/catalog/Category/education-courses" + } + }, + "categories.1": { + "links": { + "self": "https://app.boxel.ai/catalog/Category/ai-automation" + } + }, + "examples.0": { + "links": { + "self": "../../EchoPad/arithmetic-check" + } + }, + "examples.1": { + "links": { + "self": "../../EchoPad/wave-study" + } + }, + "examples.2": { + "links": { + "self": "../../EchoPad/diagram-complete" + } + }, + "images.0": { + "links": { + "self": "../Screenshots/ambient-marking-3c91d4.png" + } + }, + "images.1": { + "links": { + "self": "../Screenshots/wave-morph-scene-7be2a1.png" + } + }, + "license": { + "links": { + "self": null + } + }, + "publisher": { + "links": { + "self": null + } + }, + "skills.0": { + "links": { + "self": "../Skill/echo-marker" + } + }, + "tags.0": { + "links": { + "self": "https://app.boxel.ai/catalog/Tag/ai" + } + }, + "tags.1": { + "links": { + "self": "https://app.boxel.ai/catalog/Tag/app" + } + }, + "specs.0": { + "links": { + "self": "../Spec/bb45a3c4-c96b-4a49-b903-2426be4001b5" + } + }, + "specs.1": { + "links": { + "self": "../Spec/75b1c152-d2cf-401d-8c66-dde32f735a7c" + } + }, + "specs.2": { + "links": { + "self": "../Spec/2de99ec8-608e-4378-a9a4-ad4db70b3427" + } + }, + "specs.3": { + "links": { + "self": "../Spec/5a94d17e-1a3c-47ab-95d8-59cb5104e30d" + } + }, + "specs.4": { + "links": { + "self": "../Spec/09862dce-770f-4e34-b0fe-c0f0ae65e43d" + } + }, + "specs.5": { + "links": { + "self": "../Spec/04196a72-1b33-46cb-adc7-11f105c12fcc" + } + }, + "specs.6": { + "links": { + "self": "../Spec/2e1b2b22-045a-4ae6-8684-a3eccfe78daf" + } + }, + "specs.7": { + "links": { + "self": "../Spec/b87dbcab-3f75-4de7-a690-af41c6cf9e2b" + } + }, + "specs.8": { + "links": { + "self": "../Spec/1535c33b-d0d6-4f89-9ed1-bd305c5ed882" + } + }, + "specs.9": { + "links": { + "self": "../Spec/3552a1a1-dfc4-4344-9270-dd7b683fb87b" + } + }, + "specs.10": { + "links": { + "self": "../Spec/5ebd1f7e-043d-4632-8866-b5c6c10eea7f" + } + }, + "specs.11": { + "links": { + "self": "../Spec/e565fb4c-1eb6-4e36-a5fd-ac51eeb2b934" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://app.boxel.ai/catalog/catalog-app/listing/listing", + "name": "CardListing" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/ListingThumbnails/echo-pad-1c0f5c.png b/f22ed9-echo-pad/echo-pad/ListingThumbnails/echo-pad-1c0f5c.png new file mode 100644 index 00000000..1513eb1b Binary files /dev/null and b/f22ed9-echo-pad/echo-pad/ListingThumbnails/echo-pad-1c0f5c.png differ diff --git a/f22ed9-echo-pad/echo-pad/Screenshots/ambient-marking-3c91d4.png b/f22ed9-echo-pad/echo-pad/Screenshots/ambient-marking-3c91d4.png new file mode 100644 index 00000000..de4f3046 Binary files /dev/null and b/f22ed9-echo-pad/echo-pad/Screenshots/ambient-marking-3c91d4.png differ diff --git a/f22ed9-echo-pad/echo-pad/Screenshots/wave-morph-scene-7be2a1.png b/f22ed9-echo-pad/echo-pad/Screenshots/wave-morph-scene-7be2a1.png new file mode 100644 index 00000000..629ed36b Binary files /dev/null and b/f22ed9-echo-pad/echo-pad/Screenshots/wave-morph-scene-7be2a1.png differ diff --git a/f22ed9-echo-pad/echo-pad/Skill/echo-marker.json b/f22ed9-echo-pad/echo-pad/Skill/echo-marker.json new file mode 100644 index 00000000..c78eba9b --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Skill/echo-marker.json @@ -0,0 +1,17 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Echo Marker", + "cardDescription": "The red pen that annotates an Echo Pad board: reads a circled region of handwriting or sketch and returns a margin note, an optional sketch drawn back onto the paper, and an optional manim animation scene.", + "instructions": "You are a red pen annotating a handwritten board. The image you are shown is a cropped region of that board — handwriting and rough sketches the user circled. It could be math, a question in words, a diagram, a list, or a half-formed idea.\n\nReply with the annotation text ONLY — no preamble, no markdown fences around the prose, no headings. If math is involved, write it in plain unicode (v₀, θ, ², ×, ÷, ≈, √), never LaTeX. Keep it under 8 short lines, sized for a margin note.\n\nMODES — the request names one:\n- solve: Solve the problem shown in the handwriting. Show the key steps briefly, then the final answer.\n- check: The handwriting contains the user's own worked solution or claim. Mark it like a kind but precise grader: if it is correct, reply with a short confirmation ending in ✓; if it is wrong, name the first incorrect step and give the corrected result ending in ✗ → the right answer.\n- complete: The sketch or working is unfinished. Infer the intent and supply the missing part.\n- explain: Explain what the handwriting and sketch describe, concisely, as an annotation.\n- animate: Compose a short animation that shows the idea in motion. Strongly prefer a scene block; keep the annotation text to at most 2 short lines.\n- auto: Respond helpfully to whatever the ink is reaching for: solve a problem, answer a question, complete a sketch or list, or briefly explain — whichever fits. If nothing clearly calls for a response, reply with exactly NOTHING.\n\nSKETCHING BACK: if a small sketch genuinely helps (completing a diagram, drawing the missing part, an arrow, a quick plot), append one fenced block exactly like ```draw {\"polylines\":[[x1,y1,x2,y2,...]]}``` — coordinates are PIXELS in the image you were shown (0,0 = its top-left), so your lines land exactly where you place them on the drawing. At most 6 polylines. NEVER form characters out of polylines — to write numbers, words or expressions on the paper, add \"write\" entries to the same block: {\"polylines\":[...],\"write\":[{\"text\":\"14\",\"at\":[x,y],\"size\":44}]} — at is the top-left of the text in image pixels, size its height in pixels; the board renders it in red handwriting exactly there. Use polylines only for shapes, arrows and plots.\n\nANIMATION: prefer composing your own clean, precise graphs and diagrams — that is what makes an animation valuable. Append ONE fenced block exactly like ```scene {\"title\":\"...\",\"steps\":[...]}```. Each step is one JSON object. Object steps: {\"add\":TYPE,\"id\":\"unique\",...props}. TYPE choices: ink{} — the user's circled strokes as one object — should ONLY be used as the starting shape of a transform into the ideal form you draw (rough sketch morphs into the true curve); never animate the raw ink for its own sake · axes{xRange:[a,b],yRange:[a,b]} · plane{xRange,yRange} · graph{fn:\"expr in x\",axes:\"axesId\",xRange:[a,b]} · circle{radius} · square{side} · rect{w,h} · line{from:[x,y],to:[x,y]} · arrow{from,to} · vector{to:[x,y]} · dot{at:[x,y]} · text{value,at} · mathtex{value:\"LaTeX\",at}. Optional props: color (a #RGB / #RGBA / #RRGGBB / #RRGGBBAA hex value, or the string \"accent\" to inherit the board's own marker color), at:[x,y]. Coordinates are manim world units, roughly x -6..6, y -3.5..3.5, origin center. Animation steps: {\"play\":KIND,\"target\":\"id\",...}: create · write · fadeIn · fadeOut · transform{to:\"otherId\"} · rotate{angle:radians} · scale{factor} · wave{amplitude} · orbit{path:\"idOfCircleOrLine\"} · indicate · shear{matrix:[[a,b],[c,d]]} · wait{duration}. Every play may set duration in seconds (max 4). Max 16 steps. fn expressions may use x, sin, cos, tan, exp, log, sqrt, abs, pi — nothing else. Example: ```scene {\"title\":\"sine forms\",\"steps\":[{\"add\":\"axes\",\"id\":\"ax\",\"xRange\":[-5,5],\"yRange\":[-2,2]},{\"add\":\"graph\",\"id\":\"g\",\"fn\":\"sin(x)\",\"axes\":\"ax\"},{\"play\":\"create\",\"target\":\"g\",\"duration\":2},{\"play\":\"wave\",\"target\":\"g\",\"amplitude\":0.4,\"duration\":2}]}```", + "commands": [] + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/skill", + "name": "Skill" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/04196a72-1b33-46cb-adc7-11f105c12fcc.json b/f22ed9-echo-pad/echo-pad/Spec/04196a72-1b33-46cb-adc7-11f105c12fcc.json new file mode 100644 index 00000000..34bf4bb2 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/04196a72-1b33-46cb-adc7-11f105c12fcc.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "The full board app: three-layer camera sandwich (canvas ink, SVG overlays, DOM slips), five-mode Select puck, ambient auto-scan, zoom cluster, drafting title block, and draft/accept/undo flows.", + "ref": { + "module": "../components/isolated", + "name": "EchoPadIsolated" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "EchoPadIsolated", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/09862dce-770f-4e34-b0fe-c0f0ae65e43d.json b/f22ed9-echo-pad/echo-pad/Spec/09862dce-770f-4e34-b0fe-c0f0ae65e43d.json new file mode 100644 index 00000000..b8b94ac2 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/09862dce-770f-4e34-b0fe-c0f0ae65e43d.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Output of RecognizeInkCommand: annotation text, plus optional sketchJson/sceneJson payloads already validated by the pure helpers in utils.", + "ref": { + "module": "../commands/recognize-ink-command", + "name": "RecognizeInkOutput" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "RecognizeInkOutput", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/1535c33b-d0d6-4f89-9ed1-bd305c5ed882.json b/f22ed9-echo-pad/echo-pad/Spec/1535c33b-d0d6-4f89-9ed1-bd305c5ed882.json new file mode 100644 index 00000000..0b726b62 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/1535c33b-d0d6-4f89-9ed1-bd305c5ed882.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Designed edit format: board name, read-only facts, per-echo removal, and a guarded erase — the raw ink/viewport JSON internals are never exposed.", + "ref": { + "module": "../components/edit", + "name": "EchoPadEdit" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "EchoPadEdit", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/2de99ec8-608e-4378-a9a4-ad4db70b3427.json b/f22ed9-echo-pad/echo-pad/Spec/2de99ec8-608e-4378-a9a4-ad4db70b3427.json new file mode 100644 index 00000000..c01ebc50 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/2de99ec8-608e-4378-a9a4-ad4db70b3427.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Sends the circled ink (PNG data URL + mode + Skill-card instructions) through the request-forward proxy to a vision model and returns validated text/sketch/scene channels. Caches by capture hash so identical asks are never paid for twice.", + "ref": { + "module": "../commands/recognize-ink-command", + "name": "RecognizeInkCommand" + }, + "specType": "command", + "containedExamples": [], + "cardTitle": "RecognizeInkCommand", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/2e1b2b22-045a-4ae6-8684-a3eccfe78daf.json b/f22ed9-echo-pad/echo-pad/Spec/2e1b2b22-045a-4ae6-8684-a3eccfe78daf.json new file mode 100644 index 00000000..77ca6b44 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/2e1b2b22-045a-4ae6-8684-a3eccfe78daf.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Embedded summary: ink glance beside the newest note and board facts.", + "ref": { + "module": "../components/formats", + "name": "EchoPadEmbedded" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "EchoPadEmbedded", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/3552a1a1-dfc4-4344-9270-dd7b683fb87b.json b/f22ed9-echo-pad/echo-pad/Spec/3552a1a1-dfc4-4344-9270-dd7b683fb87b.json new file mode 100644 index 00000000..c92bf270 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/3552a1a1-dfc4-4344-9270-dd7b683fb87b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Poster-first manim player: lazy-loads the vendored runtime, one active scene at a time, paper mode for ink scenes and darkroom for composed ones, full teardown on close.", + "ref": { + "module": "../components/scene-player", + "name": "EchoScenePlayer" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "EchoScenePlayer", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/5a94d17e-1a3c-47ab-95d8-59cb5104e30d.json b/f22ed9-echo-pad/echo-pad/Spec/5a94d17e-1a3c-47ab-95d8-59cb5104e30d.json new file mode 100644 index 00000000..6c22af2c --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/5a94d17e-1a3c-47ab-95d8-59cb5104e30d.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Input for RecognizeInkCommand: the ephemeral image crop, mode, board name, and the marker instructions resolved from the bundled Skill card by the board (plain realm commands cannot reach the store).", + "ref": { + "module": "../commands/recognize-ink-command", + "name": "RecognizeInkInput" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "RecognizeInkInput", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/5ebd1f7e-043d-4632-8866-b5c6c10eea7f.json b/f22ed9-echo-pad/echo-pad/Spec/5ebd1f7e-043d-4632-8866-b5c6c10eea7f.json new file mode 100644 index 00000000..d732ce4a --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/5ebd1f7e-043d-4632-8866-b5c6c10eea7f.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Pure validation core for model responses: splits text, draw polylines, write labels and scene blocks, clamping every channel. No card imports; unit-tested.", + "ref": { + "module": "../utils/index", + "name": "splitEchoResponse" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "splitEchoResponse", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/75b1c152-d2cf-401d-8c66-dde32f735a7c.json b/f22ed9-echo-pad/echo-pad/Spec/75b1c152-d2cf-401d-8c66-dde32f735a7c.json new file mode 100644 index 00000000..131be09f --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/75b1c152-d2cf-401d-8c66-dde32f735a7c.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "An accepted response from the marker: mode, red-pen text, board position, plus optional `sketchJson` ({polylines, labels} in world coordinates) and a validated `sceneJson` manim spec.", + "ref": { + "module": "../fields/echo-note", + "name": "EchoNoteField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "EchoNoteField", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/b87dbcab-3f75-4de7-a690-af41c6cf9e2b.json b/f22ed9-echo-pad/echo-pad/Spec/b87dbcab-3f75-4de7-a690-af41c6cf9e2b.json new file mode 100644 index 00000000..b913aedf --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/b87dbcab-3f75-4de7-a690-af41c6cf9e2b.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Fitted views for all 16 preview sizes via container queries: badge (mark only), strips, tiles and cards, each with the ink glance and echo badge where space allows.", + "ref": { + "module": "../components/formats", + "name": "EchoPadFitted" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "EchoPadFitted", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/bb45a3c4-c96b-4a49-b903-2426be4001b5.json b/f22ed9-echo-pad/echo-pad/Spec/bb45a3c4-c96b-4a49-b903-2426be4001b5.json new file mode 100644 index 00000000..e7cb4d45 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/bb45a3c4-c96b-4a49-b903-2426be4001b5.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "# Echo Pad\n\n## Summary\n\nAn infinite drafting-vellum whiteboard where an AI marker answers in red pen, directly on the paper. Write or sketch in blue ink; circle a region with Select and ask for one of five things — Solve, Check, Complete, Explain, or Animate — or leave Auto on and the marker quietly follows along, proposing help when your ink pauses. Every response arrives as a draft you accept or dismiss: a margin note in red handwriting, an answer written back onto the page, a sketch drawn onto your diagram, or a short manim animation that plays inside a projection slip on the board.\n\n## How it works\n\n- **Ink model** — strokes are flat point arrays in a single `inkJson` field with velocity-based width and midpoint smoothing; saves are debounced, never per-point.\n- **The marker** — the circled region is cropped to a PNG and sent through the realm's request-forward proxy to a vision model. Prompt substance lives in the bundled `Skill/echo-marker` card, so the marker's voice can be read and remixed as data; a code fallback covers realms without the Skill. Identical asks are served from an in-session cache.\n- **Response channels** — plain text (margin note), `write` entries (text the board renders in its red hand at model-chosen coordinates), `draw` polylines (shapes anchored to your ink), and a `scene` block in a whitelisted grammar that a deterministic interpreter plays through the vendored manim-web runtime. The model never supplies executable code; every channel is validated data.\n- **Ambient mode** — pen-idle detection plus guards (minimum new strokes and area, suppression after silence) surfaces a pulsing dot; it auto-runs after a grace window and costs nothing until it fires.\n- **Theming** — the board's tokens chain through the Boxel theme system only when a Theme card is linked; unthemed boards keep their committed vellum-and-ballpoint identity.\n\n## Limitation\n\nOne open session per board: saves are whole-document last-writer-wins, so two simultaneous editors overwrite each other. Duplicate the board for parallel work.\n\n## Credits\n\nInspired by the open-source PenEcho whiteboard (independent reimplementation — no code reuse). Animation runtime: manim-web (MIT), vendored under `lib/` with license and manifest.", + "ref": { + "module": "../echo-pad", + "name": "EchoPad" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Echo Pad", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/Spec/e565fb4c-1eb6-4e36-a5fd-ac51eeb2b934.json b/f22ed9-echo-pad/echo-pad/Spec/e565fb4c-1eb6-4e36-a5fd-ac51eeb2b934.json new file mode 100644 index 00000000..455609e3 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/Spec/e565fb4c-1eb6-4e36-a5fd-ac51eeb2b934.json @@ -0,0 +1,28 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "Whitelisted scene-grammar validator plus a recursive-descent math expression parser (no eval) for function graphs.", + "ref": { + "module": "../utils/scene", + "name": "validateSceneSpec" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "validateSceneSpec", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/f22ed9-echo-pad/echo-pad/commands/recognize-ink-command.gts b/f22ed9-echo-pad/echo-pad/commands/recognize-ink-command.gts new file mode 100644 index 00000000..3ae1ff3a --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/commands/recognize-ink-command.gts @@ -0,0 +1,138 @@ +import { CardDef, contains, field } from '@cardstack/base/card-api'; +import StringField from '@cardstack/base/string'; +import { Command } from '@cardstack/runtime-common'; +import SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy'; + +import { + asEchoMode, + buildEchoPrompt, + echoCacheKey, + splitEchoResponse, +} from '../utils/index'; + +export class RecognizeInkInput extends CardDef { + @field imageDataUrl = contains(StringField); // ephemeral PNG crop of the lassoed region, never persisted + @field mode = contains(StringField); + @field boardName = contains(StringField); + // the marker's prose, read from the bundled Skill card by the board (a plain + // realm Command has no store access); empty falls back to the local grammar + @field instructions = contains(StringField); +} + +export class RecognizeInkOutput extends CardDef { + @field content = contains(StringField); + @field mode = contains(StringField); + @field sketchJson = contains(StringField); // polylines the model drew back, if any + @field sceneJson = contains(StringField); // validated manim scene spec, if any +} + +const VISION_MODEL = 'anthropic/claude-sonnet-5'; +const CACHE_LIMIT = 24; +// Identical ink + mode must never be paid for twice: the region hash keys a +// small LRU of responses for the session. +const echoCache = new Map< + string, + { content: string; sketchJson: string; sceneJson: string } +>(); + +export class RecognizeInkCommand extends Command< + typeof RecognizeInkInput, + typeof RecognizeInkOutput +> { + static actionVerb = 'Read'; + static displayName = 'Read Circled Ink'; + + async getInputType() { + return RecognizeInkInput; + } + + protected async run(input: RecognizeInkInput): Promise { + let { imageDataUrl, mode, boardName, instructions } = input; + if (!imageDataUrl?.startsWith('data:image/')) { + throw new Error('No ink image was captured.'); + } + let modeKey = asEchoMode(mode); + + let cacheKey = echoCacheKey(imageDataUrl, modeKey, instructions ?? ''); + let hit = echoCache.get(cacheKey); + if (hit) { + // refresh recency + echoCache.delete(cacheKey); + echoCache.set(cacheKey, hit); + let cached = new RecognizeInkOutput(); + cached.content = hit.content; + cached.mode = modeKey; + cached.sketchJson = hit.sketchJson; + cached.sceneJson = hit.sceneJson; + return cached; + } + + let promptText = + instructions && instructions.trim().length > 40 + ? `${instructions.trim()}\n\nMode: ${modeKey}.${ + boardName ? ` The board is titled "${boardName}".` : '' + }` + : buildEchoPrompt(modeKey, boardName); + + let result = await new SendRequestViaProxyCommand( + this.commandContext, + ).execute({ + url: 'https://openrouter.ai/api/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: VISION_MODEL, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: promptText }, + { type: 'image_url', image_url: { url: imageDataUrl } }, + ], + }, + ], + }), + }); + + if (!result.response.ok) { + let body = ''; + try { + body = await result.response.text(); + } catch { + // fall through to statusText + } + throw new Error( + `Echo request failed (${result.response.status}): ${ + body || result.response.statusText + }`, + ); + } + + let data = await result.response.json(); + let split = splitEchoResponse(data?.choices?.[0]?.message?.content); + if (!split.text && !split.polylines && !split.labels && !split.scene) { + throw new Error('The model returned an empty annotation.'); + } + + let output = new RecognizeInkOutput(); + output.content = split.text ?? ''; + output.mode = modeKey; + output.sketchJson = + split.polylines || split.labels + ? JSON.stringify({ + polylines: split.polylines ?? [], + labels: split.labels ?? [], + }) + : ''; + output.sceneJson = split.scene ? JSON.stringify(split.scene) : ''; + + echoCache.set(cacheKey, { + content: output.content, + sketchJson: output.sketchJson, + sceneJson: output.sceneJson, + }); + if (echoCache.size > CACHE_LIMIT) { + echoCache.delete(echoCache.keys().next().value as string); + } + return output; + } +} diff --git a/f22ed9-echo-pad/echo-pad/components/edit.gts b/f22ed9-echo-pad/echo-pad/components/edit.gts new file mode 100644 index 00000000..99df7ffd --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/components/edit.gts @@ -0,0 +1,220 @@ +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; + +import { FieldContainer } from '@cardstack/boxel-ui/components'; +import { Component } from '@cardstack/base/card-api'; + +import { parseInk } from '../utils/index'; +import type { EchoPad } from '../echo-pad'; + +// The default edit form would expose inkJson / viewportJson as raw textareas — +// hostile to edit and easy to corrupt. This form offers only what a person +// would sensibly change, and summarizes the machine-owned state read-only. +export class EchoPadEdit extends Component { + get strokeCount(): number { + return parseInk(this.args.model?.inkJson).strokes.length; + } + + get echoes() { + return (this.args.model?.echoes ?? []).filter(Boolean); + } + + removeEcho = (index: number) => { + let model = this.args.model; + if (!model) { + return; + } + model.echoes = this.echoes.filter((_, i) => i !== index); + }; + + clearInk = () => { + let model = this.args.model; + if (!model) { + return; + } + model.inkJson = ''; + model.viewportJson = ''; + }; + + +} diff --git a/f22ed9-echo-pad/echo-pad/components/formats.gts b/f22ed9-echo-pad/echo-pad/components/formats.gts new file mode 100644 index 00000000..c7db1334 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/components/formats.gts @@ -0,0 +1,547 @@ +import { Component } from '@cardstack/base/card-api'; + +import { parseInk, strokeBBox, unionBBox, type InkDoc } from '../utils/index'; +import type { EchoPad } from '../echo-pad'; + +function inkPaths(doc: InkDoc): { d: string; w: number }[] { + return doc.strokes.map((s) => { + let d = `M ${s.pts[0]} ${s.pts[1]}`; + for (let i = 2; i < s.pts.length; i += 2) { + d += ` L ${s.pts[i]} ${s.pts[i + 1]}`; + } + return { d, w: s.w }; + }); +} + +function inkViewBox(doc: InkDoc): string { + let box = unionBBox(doc.strokes.map(strokeBBox)); + if (!box) { + return '0 0 100 100'; + } + let pad = 24; + return `${box.minX - pad} ${box.minY - pad} ${ + box.maxX - box.minX + pad * 2 + } ${box.maxY - box.minY + pad * 2}`; +} + +class InkGlance { + paths: { d: string; w: number }[]; + viewBox: string; + constructor(json: string | undefined | null) { + let doc = parseInk(json); + this.paths = inkPaths(doc); + this.viewBox = inkViewBox(doc); + } +} + +export class EchoPadEmbedded extends Component { + get ink(): InkGlance { + return new InkGlance(this.args.model?.inkJson); + } + + get themed(): boolean { + return Boolean((this.args.model as any)?.cardInfo?.theme); + } + + get echoCount(): number { + return (this.args.model?.echoes ?? []).filter(Boolean).length; + } + + get strokeCount(): number { + return this.ink.paths.length; + } + + get echoLabel(): string { + let n = this.echoCount; + return `${n} ${n === 1 ? 'echo' : 'echoes'}`; + } + + get firstEchoText(): string { + let first = (this.args.model?.echoes ?? []).filter(Boolean)[0]; + let text = first?.content ?? ''; + return text.split('\n')[0] ?? ''; + } + + +} + +export class EchoPadFitted extends Component { + get ink(): InkGlance { + return new InkGlance(this.args.model?.inkJson); + } + + get themed(): boolean { + return Boolean((this.args.model as any)?.cardInfo?.theme); + } + + get echoCount(): number { + return (this.args.model?.echoes ?? []).filter(Boolean).length; + } + + get strokeCount(): number { + return this.ink.paths.length; + } + + get echoLabel(): string { + let n = this.echoCount; + return `${n} ${n === 1 ? 'echo' : 'echoes'}`; + } + + get firstEchoText(): string { + let first = (this.args.model?.echoes ?? []).filter(Boolean)[0]; + let text = first?.content ?? ''; + return text.split('\n')[0] ?? ''; + } + + +} diff --git a/f22ed9-echo-pad/echo-pad/components/isolated.gts b/f22ed9-echo-pad/echo-pad/components/isolated.gts new file mode 100644 index 00000000..49f1c8f9 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/components/isolated.gts @@ -0,0 +1,2244 @@ +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { htmlSafe, type SafeString } from '@ember/template'; +import { modifier } from 'ember-modifier'; +import { restartableTask, timeout } from 'ember-concurrency'; + +import EraserIcon from '@cardstack/boxel-icons/eraser'; +import HandIcon from '@cardstack/boxel-icons/hand'; +import LassoIcon from '@cardstack/boxel-icons/lasso'; +import PencilIcon from '@cardstack/boxel-icons/pencil'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { Component } from '@cardstack/base/card-api'; + +import { + RecognizeInkCommand, + RecognizeInkInput, +} from '../commands/recognize-ink-command'; +import { EchoScenePlayer } from './scene-player'; +import { mapStrokesToManim } from '../utils/scene'; +import { EchoNoteField } from '../fields/echo-note'; +import { + asEchoMode, + clamp, + ECHO_MODES, + INK_COLOR, + mapLabelsToWorld, + mapPolylinesToWorld, + parseSketchJson, + type EchoLabel, + parseInk, + polylinesBBox, + polylinesToPaths, + serializeInk, + strokeBBox, + strokeInLasso, + strokeNearPoint, + unionBBox, + ZOOM_MAX, + ZOOM_MIN, + type BBox, + type EchoModeKey, + type InkStroke, +} from '../utils/index'; +import type { EchoPad } from '../echo-pad'; + +type Tool = 'pen' | 'eraser' | 'lasso' | 'pan'; + +type Draft = { + content: string; + mode: EchoModeKey; + sketchWorld: number[][] | null; // world-space polylines, ready to ink onto the board + labelsWorld: EchoLabel[] | null; // world-space write-backs, rendered in the red hand + sceneJson: string; // validated manim scene spec ('' when none) + x: number; + y: number; + width: number; +}; + +type EchoErrorSlip = { + message: string; + mode: EchoModeKey; + x: number; + y: number; +}; + +const ERASER_RADIUS = 10; // world units +const DRAFT_WIDTH = 340; +const TITLE_BLOCK_SCREEN = { w: 320, h: 96 }; // docked bottom-right, screen space +const DRAFT_MIN_SCREEN_H = 150; +const CROP_MARGIN = 48; +const CROP_MAX_DIM = 1024; +const UNDO_DEPTH = 30; +const AUTO_IDLE_MS = 3000; +const AUTO_MIN_STROKES = 3; +const AUTO_MIN_AREA = 900; // world px² — ignore tiny scribbles +const AUTO_GRACE_MS = 1800; // dot lingers, then fires by itself (Esc cancels) + +export class EchoPadIsolated extends Component { + @tracked tool: Tool = 'pen'; + @tracked zoom = 1; + @tracked panX = 0; + @tracked panY = 0; + @tracked strokeCount = 0; + @tracked lassoPts: number[] | null = null; // world coords, closed on pointer-up + @tracked lassoBBox: BBox | null = null; + @tracked thinkingLabel: string | null = null; + @tracked draft: Draft | null = null; + @tracked echoError: EchoErrorSlip | null = null; + @tracked clearArmed = false; + @tracked toast: { message: string; undo: () => void } | null = null; + @tracked spaceHeld = false; + @tracked noteDrag: { index: number; x: number; y: number } | null = null; + @tracked autoEnabled = true; + @tracked autoDot: { x: number; y: number; box: BBox } | null = null; + + strokes: InkStroke[] = []; + undoStack: string[] = []; + canvas: HTMLCanvasElement | null = null; + boardEl: HTMLElement | null = null; + resizeObserver: ResizeObserver | null = null; + activePointer: { + kind: 'draw' | 'erase' | 'lasso' | 'pan' | 'drag-draft' | 'drag-note'; + id: number; + lastX: number; + lastY: number; + noteIndex?: number; + } | null = null; + currentStroke: InkStroke | null = null; + lastEchoBBox: BBox | null = null; + echoBaselineCount = 0; // strokes already covered by a previous echo + autoSuppressedBelow = 0; // ignore auto until this many strokes exist + clearArmTimer: number | undefined; + toastTimer: number | undefined; + keydownHandler = (e: KeyboardEvent) => this.onKeyDown(e); + keyupHandler = (e: KeyboardEvent) => this.onKeyUp(e); + + inkLoaded = false; + + setupBoard = modifier((el: HTMLElement) => { + this.boardEl = el; + let canvas = el.querySelector('canvas.ink-layer') as HTMLCanvasElement; + this.canvas = canvas; + // one-shot: our own debounced save mutates inkJson, which re-runs this + // modifier — re-seeding from the model then would clobber live state + // (and reset the ambient-scan baseline before the scan can fire) + if (!this.inkLoaded) { + this.inkLoaded = true; + this.strokes = parseInk(this.args.model?.inkJson).strokes; + this.strokeCount = this.strokes.length; + this.echoBaselineCount = this.strokes.length; + this.restoreViewport(); + } + this.resizeObserver = new ResizeObserver(() => this.fitCanvas()); + this.resizeObserver.observe(el); + this.fitCanvas(); + window.addEventListener('keydown', this.keydownHandler); + window.addEventListener('keyup', this.keyupHandler); + return () => { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + window.removeEventListener('keydown', this.keydownHandler); + window.removeEventListener('keyup', this.keyupHandler); + }; + }); + + willDestroy(): void { + super.willDestroy(); + window.removeEventListener('keydown', this.keydownHandler); + window.removeEventListener('keyup', this.keyupHandler); + window.clearTimeout(this.clearArmTimer); + window.clearTimeout(this.toastTimer); + } + + // ---- camera ---- + + restoreViewport() { + try { + let v = JSON.parse(this.args.model?.viewportJson ?? ''); + if (v && typeof v === 'object') { + this.zoom = clamp(Number(v.zoom) || 1, ZOOM_MIN, ZOOM_MAX); + this.panX = Number(v.x) || 0; + this.panY = Number(v.y) || 0; + } + } catch { + // fresh board: defaults stand + } + } + + get worldTransform(): SafeString { + return htmlSafe( + `transform: translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`, + ); + } + + get zoomPercent() { + return Math.round(this.zoom * 100); + } + + toWorld(clientX: number, clientY: number): [number, number] { + let rect = this.boardEl?.getBoundingClientRect(); + let sx = clientX - (rect?.left ?? 0); + let sy = clientY - (rect?.top ?? 0); + return [(sx - this.panX) / this.zoom, (sy - this.panY) / this.zoom]; + } + + zoomBy(factor: number) { + let el = this.boardEl; + if (!el) { + return; + } + let cx = el.clientWidth / 2; + let cy = el.clientHeight / 2; + let next = clamp(this.zoom * factor, ZOOM_MIN, ZOOM_MAX); + this.panX = cx - ((cx - this.panX) / this.zoom) * next; + this.panY = cy - ((cy - this.panY) / this.zoom) * next; + this.zoom = next; + this.redraw(); + this.scheduleSave(); + } + + zoomIn = () => this.zoomBy(1.25); + zoomOut = () => this.zoomBy(0.8); + zoomReset = () => this.zoomBy(1 / this.zoom); + + onWheel = (event: Event) => { + let e = event as WheelEvent; + e.preventDefault(); + let rect = this.boardEl?.getBoundingClientRect(); + let sx = e.clientX - (rect?.left ?? 0); + let sy = e.clientY - (rect?.top ?? 0); + let factor = Math.exp(-e.deltaY * 0.0015); + let next = clamp(this.zoom * factor, ZOOM_MIN, ZOOM_MAX); + // keep the world point under the cursor fixed while zooming + this.panX = sx - ((sx - this.panX) / this.zoom) * next; + this.panY = sy - ((sy - this.panY) / this.zoom) * next; + this.zoom = next; + this.redraw(); + this.scheduleSave(); + }; + + // ---- pointer interaction ---- + + onPointerDown = (event: Event) => { + let e = event as PointerEvent; + if (e.button !== 0 && e.button !== 1) { + return; + } + let el = e.currentTarget as HTMLElement; + try { + el.setPointerCapture(e.pointerId); + } catch { + // synthetic events have no active pointer to capture + } + let panning = this.tool === 'pan' || this.spaceHeld || e.button === 1; + let [wx, wy] = this.toWorld(e.clientX, e.clientY); + + if (panning) { + this.activePointer = { + kind: 'pan', + id: e.pointerId, + lastX: e.clientX, + lastY: e.clientY, + }; + return; + } + if (this.tool === 'pen') { + this.pushUndo(); + this.autoDot = null; + this.currentStroke = { w: 2.5, pts: [wx, wy] }; + this.activePointer = { + kind: 'draw', + id: e.pointerId, + lastX: wx, + lastY: wy, + }; + return; + } + if (this.tool === 'eraser') { + this.pushUndo(); + this.eraseAt(wx, wy); + this.activePointer = { + kind: 'erase', + id: e.pointerId, + lastX: wx, + lastY: wy, + }; + return; + } + if (this.tool === 'lasso') { + this.dismissLasso(); + this.lassoPts = [wx, wy]; + this.activePointer = { + kind: 'lasso', + id: e.pointerId, + lastX: wx, + lastY: wy, + }; + } + }; + + onPointerMove = (event: Event) => { + let e = event as PointerEvent; + let ap = this.activePointer; + if (!ap || ap.id !== e.pointerId) { + return; + } + if (ap.kind === 'pan') { + this.panX += e.clientX - ap.lastX; + this.panY += e.clientY - ap.lastY; + ap.lastX = e.clientX; + ap.lastY = e.clientY; + this.redraw(); + return; + } + let [wx, wy] = this.toWorld(e.clientX, e.clientY); + if (ap.kind === 'draw' && this.currentStroke) { + let dx = wx - ap.lastX; + let dy = wy - ap.lastY; + if (dx * dx + dy * dy < 1) { + return; // drop sub-pixel jitter + } + this.currentStroke.pts.push(wx, wy); + ap.lastX = wx; + ap.lastY = wy; + this.drawSegment(this.currentStroke); + return; + } + if (ap.kind === 'erase') { + this.eraseAt(wx, wy); + return; + } + if (ap.kind === 'lasso' && this.lassoPts) { + this.lassoPts = [...this.lassoPts, wx, wy]; + return; + } + if (ap.kind === 'drag-draft' && this.draft) { + this.draft = { ...this.draft, x: wx - 20, y: wy - 12 }; + return; + } + if (ap.kind === 'drag-note' && ap.noteIndex !== undefined) { + this.noteDrag = { index: ap.noteIndex, x: wx - 20, y: wy - 12 }; + } + }; + + onPointerUp = (event: Event) => { + let e = event as PointerEvent; + let ap = this.activePointer; + if (!ap || ap.id !== e.pointerId) { + return; + } + this.activePointer = null; + if (ap.kind === 'draw' && this.currentStroke) { + if (this.currentStroke.pts.length >= 4) { + this.strokes.push(this.currentStroke); + this.strokeCount = this.strokes.length; + } + this.currentStroke = null; + this.redraw(); + this.scheduleSave(); + this.autoScanTask.perform(); + return; + } + if (ap.kind === 'erase') { + this.scheduleSave(); + return; + } + if (ap.kind === 'lasso') { + this.closeLasso(); + return; + } + if (ap.kind === 'drag-note' && this.noteDrag) { + this.commitNotePosition(this.noteDrag); + this.noteDrag = null; + } + }; + + onKeyDown(e: KeyboardEvent) { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; + } + if (e.code === 'Space') { + this.spaceHeld = true; + e.preventDefault(); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { + e.preventDefault(); + this.undo(); + return; + } + let key = e.key.toLowerCase(); + if (key === 'p') this.tool = 'pen'; + if (key === 'e') this.tool = 'eraser'; + if (key === 'l' || key === 's') this.tool = 'lasso'; + if (key === 'v' || key === 'h') this.tool = 'pan'; + if (key === 'escape') { + if (this.echoTask.isRunning) { + this.cancelEcho(); + } else if (this.draft) { + this.dismissDraft(); + } else if (this.autoDot) { + this.dismissAutoDot(); + } else { + this.dismissLasso(); + } + } + } + + onKeyUp(e: KeyboardEvent) { + if (e.code === 'Space') { + this.spaceHeld = false; + } + } + + pickTool = (tool: Tool) => { + this.tool = tool; + }; + + // ---- ink ---- + + fitCanvas() { + let canvas = this.canvas; + let el = this.boardEl; + if (!canvas || !el) { + return; + } + let dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(el.clientWidth * dpr)); + canvas.height = Math.max(1, Math.round(el.clientHeight * dpr)); + this.redraw(); + } + + redraw() { + let canvas = this.canvas; + let ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) { + return; + } + let dpr = window.devicePixelRatio || 1; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.setTransform( + dpr * this.zoom, + 0, + 0, + dpr * this.zoom, + dpr * this.panX, + dpr * this.panY, + ); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = INK_COLOR; + for (let s of this.strokes) { + this.strokePath(ctx, s); + } + if (this.currentStroke) { + this.strokePath(ctx, this.currentStroke); + } + } + + strokePath(ctx: CanvasRenderingContext2D, s: InkStroke) { + // ink feel: quadratic-through-midpoints smoothing, per-slice width driven + // by point spacing (a velocity proxy) — slow strokes run thick, fast thin + let p = s.pts; + let n = p.length / 2; + if (n < 2) { + return; + } + if (n === 2) { + ctx.beginPath(); + ctx.lineWidth = s.w; + ctx.moveTo(p[0], p[1]); + ctx.lineTo(p[2], p[3]); + ctx.stroke(); + return; + } + let ax = p[0]; + let ay = p[1]; + let prevW = s.w; + for (let i = 1; i < n - 1; i++) { + let cx = p[i * 2]; + let cy = p[i * 2 + 1]; + let mx = (cx + p[i * 2 + 2]) / 2; + let my = (cy + p[i * 2 + 3]) / 2; + let w = (prevW + velocityWidth(s.w, Math.hypot(mx - ax, my - ay))) / 2; + ctx.beginPath(); + ctx.lineWidth = w; + ctx.moveTo(ax, ay); + ctx.quadraticCurveTo(cx, cy, mx, my); + ctx.stroke(); + prevW = w; + ax = mx; + ay = my; + } + ctx.beginPath(); + ctx.lineWidth = prevW; + ctx.moveTo(ax, ay); + ctx.lineTo(p[n * 2 - 2], p[n * 2 - 1]); + ctx.stroke(); + } + + drawSegment(s: InkStroke) { + // incremental draw of the live stroke's newest segment; the pointer-up + // redraw replaces it with the smoothed rendering + let ctx = this.canvas?.getContext('2d'); + let n = s.pts.length; + if (!ctx || n < 4) { + return; + } + let d = Math.hypot( + s.pts[n - 2] - s.pts[n - 4], + s.pts[n - 1] - s.pts[n - 3], + ); + ctx.lineCap = 'round'; + ctx.strokeStyle = INK_COLOR; + ctx.lineWidth = velocityWidth(s.w, d); + ctx.beginPath(); + ctx.moveTo(s.pts[n - 4], s.pts[n - 3]); + ctx.lineTo(s.pts[n - 2], s.pts[n - 1]); + ctx.stroke(); + } + + eraseAt(wx: number, wy: number) { + let before = this.strokes.length; + this.strokes = this.strokes.filter( + (s) => !strokeNearPoint(s, wx, wy, ERASER_RADIUS / this.zoom + 4), + ); + if (this.strokes.length !== before) { + this.strokeCount = this.strokes.length; + this.redraw(); + } + } + + pushUndo() { + this.undoStack.push(serializeInk({ v: 1, strokes: this.strokes })); + if (this.undoStack.length > UNDO_DEPTH) { + this.undoStack.shift(); + } + } + + undo = () => { + let prev = this.undoStack.pop(); + if (prev === undefined) { + return; + } + this.strokes = parseInk(prev).strokes; + this.strokeCount = this.strokes.length; + this.redraw(); + this.scheduleSave(); + }; + + armClear = () => { + if (!this.clearArmed) { + this.clearArmed = true; + window.clearTimeout(this.clearArmTimer); + this.clearArmTimer = window.setTimeout(() => { + this.clearArmed = false; + }, 3000); + return; + } + window.clearTimeout(this.clearArmTimer); + this.clearArmed = false; + this.pushUndo(); + // "erase everything" means everything: ink, draft, AND accepted echoes + let model = this.args.model; + let prevInk = serializeInk({ v: 1, strokes: this.strokes }); + let prevEchoes = (model?.echoes ?? []).filter(Boolean); + this.strokes = []; + this.strokeCount = 0; + this.draft = null; + this.autoDot = null; + this.echoBaselineCount = 0; + this.autoSuppressedBelow = 0; + if (model && prevEchoes.length) { + model.echoes = []; + } + this.dismissLasso(); + this.redraw(); + this.commitBoardState(); + this.toast = { + message: 'Board cleared', + undo: () => { + this.strokes = parseInk(prevInk).strokes; + this.strokeCount = this.strokes.length; + this.echoBaselineCount = this.strokes.length; + if (model && prevEchoes.length) { + model.echoes = [...prevEchoes]; + } + this.redraw(); + this.commitBoardState(); + this.toast = null; + window.clearTimeout(this.toastTimer); + }, + }; + window.clearTimeout(this.toastTimer); + this.toastTimer = window.setTimeout(() => { + this.toast = null; + }, 8000); + }; + + // ---- persistence (debounced; never per-point) ---- + + saveTask = restartableTask(async () => { + await timeout(1500); + let model = this.args.model; + if (!model) { + return; + } + model.inkJson = serializeInk({ v: 1, strokes: this.strokes }); + model.viewportJson = JSON.stringify({ + x: Math.round(this.panX), + y: Math.round(this.panY), + zoom: Math.round(this.zoom * 100) / 100, + }); + }); + + scheduleSave() { + this.saveTask.perform(); + } + + // model writes that matter (accept/remove/move/clear) carry the FULL board + // state in one commit — a save-triggered card reload must be able to + // reconstitute everything, including ink still inside the debounce window + commitBoardState() { + let model = this.args.model; + if (!model) { + return; + } + this.saveTask.cancelAll(); + model.inkJson = serializeInk({ v: 1, strokes: this.strokes }); + model.viewportJson = JSON.stringify({ + x: Math.round(this.panX), + y: Math.round(this.panY), + zoom: Math.round(this.zoom * 100) / 100, + }); + } + + // ---- lasso + echo ---- + + closeLasso() { + let pts = this.lassoPts; + if (!pts || pts.length < 6) { + this.lassoPts = null; + return; + } + let captured = this.strokes.filter((s) => strokeInLasso(s, pts)); + let box = unionBBox(captured.map(strokeBBox)); + if (!box) { + // nothing inside — treat as a miss, quietly reset + this.lassoPts = null; + this.lassoBBox = null; + return; + } + this.lassoBBox = box; + } + + dismissLasso = () => { + this.lassoPts = null; + this.lassoBBox = null; + this.echoError = null; + }; + + get lassoPath(): string { + let pts = this.lassoPts; + if (!pts || pts.length < 4) { + return ''; + } + // the svg sits at (-4000, -4000) so ink at negative world coords still renders + let d = `M ${pts[0] + 4000} ${pts[1] + 4000}`; + for (let i = 2; i < pts.length; i += 2) { + d += ` L ${pts[i] + 4000} ${pts[i + 1] + 4000}`; + } + if (this.lassoBBox) { + d += ' Z'; + } + return d; + } + + get puckStyle(): SafeString { + let box = this.lassoBBox; + if (!box) { + return htmlSafe('display: none'); + } + return htmlSafe(`left: ${box.minX}px; top: ${box.maxY + 18}px`); + } + + get scanStyle(): SafeString { + let box = this.lassoBBox; + if (!box) { + return htmlSafe('display: none'); + } + return htmlSafe( + `left: ${box.minX - 8}px; top: ${box.minY - 8}px; width: ${ + box.maxX - box.minX + 16 + }px; height: ${box.maxY - box.minY + 16}px`, + ); + } + + // ambient scanning: after a pause in writing, quietly offer an echo on the + // fresh ink — the dot costs nothing; the request is only sent on click + autoScanTask = restartableTask(async () => { + await timeout(AUTO_IDLE_MS); + if ( + !this.autoEnabled || + this.draft || + this.echoError || + this.lassoBBox || + this.echoTask.isRunning || + this.strokes.length < this.autoSuppressedBelow + ) { + return; + } + let fresh = this.freshStrokes(); + if (fresh.length < AUTO_MIN_STROKES) { + return; + } + let box = unionBBox(fresh.map(strokeBBox)); + if (!box || (box.maxX - box.minX) * (box.maxY - box.minY) < AUTO_MIN_AREA) { + return; + } + this.autoDot = { x: box.maxX + 18, y: box.minY - 6, box }; + // grace window: help arrives on its own unless dismissed (or clicked sooner) + await timeout(AUTO_GRACE_MS); + if ( + this.autoDot && + this.autoEnabled && + !this.draft && + !this.echoError && + !this.echoTask.isRunning + ) { + this.expandAutoDot(); + } + }); + + freshStrokes(): InkStroke[] { + let base = Math.min(this.echoBaselineCount, this.strokes.length); + return this.strokes.slice(base); + } + + get autoDotStyle(): SafeString { + let d = this.autoDot; + if (!d) { + return htmlSafe('display: none'); + } + return htmlSafe(`left: ${d.x}px; top: ${d.y}px`); + } + + toggleAuto = () => { + this.autoEnabled = !this.autoEnabled; + if (!this.autoEnabled) { + this.autoDot = null; + } + }; + + dismissAutoDot = () => { + this.autoDot = null; + // stay quiet until a few more strokes of genuinely new ink arrive + this.autoSuppressedBelow = this.strokes.length + AUTO_MIN_STROKES; + }; + + expandAutoDot = () => { + let dot = this.autoDot; + if (!dot) { + return; + } + let b = dot.box; + this.lassoBBox = b; + this.lassoPts = [ + b.minX, + b.minY, + b.maxX, + b.minY, + b.maxX, + b.maxY, + b.minX, + b.maxY, + ]; + this.autoDot = null; + this.echoTask.perform('auto'); + }; + + echoTask = restartableTask(async (mode: EchoModeKey) => { + let box = this.lassoBBox; + let lasso = this.lassoPts; + let commandContext = this.args.context?.commandContext; + if (!box || !lasso || !commandContext) { + return; + } + this.echoError = null; + this.draft = null; + this.autoDot = null; + this.lastEchoBBox = box; + try { + this.thinkingLabel = 'Reading your ink…'; + let capture = this.captureRegion(box, lasso); + await timeout(350); // let the scan-line register before the request settles + this.thinkingLabel = 'Thinking…'; + let input = new RecognizeInkInput(); + input.instructions = await this.loadMarkerInstructions(); + input.imageDataUrl = capture.dataUrl; + input.mode = mode; + input.boardName = this.args.model?.title ?? ''; + let result = await new RecognizeInkCommand(commandContext).execute(input); + let sketchWorld: number[][] | null = null; + let labelsWorld: EchoLabel[] | null = null; + if (result.sketchJson) { + let parsed = parseSketchJson(result.sketchJson); + // basis detection must see every coordinate the model emitted + let allCoords = [ + ...parsed.polylines.flat(), + ...parsed.labels.flatMap((l) => [l.x, l.y]), + ]; + if (parsed.polylines.length) { + sketchWorld = mapPolylinesToWorld( + parsed.polylines, + box, + CROP_MARGIN, + capture.width, + capture.height, + allCoords, + ); + } + if (parsed.labels.length) { + labelsWorld = mapLabelsToWorld( + parsed.labels, + allCoords, + box, + CROP_MARGIN, + capture.width, + capture.height, + ); + } + } + let sceneJson = result.sceneJson ?? ''; + if (sceneJson) { + try { + let spec = JSON.parse(sceneJson); + if ( + Array.isArray(spec?.steps) && + spec.steps.some((st: any) => st?.add === 'ink') + ) { + let captured = this.strokes.filter((st) => + strokeInLasso(st, lasso), + ); + spec.ink = mapStrokesToManim(captured, box); + sceneJson = JSON.stringify(spec); + } + } catch { + // a malformed spec just plays without the ink object + } + } + let place = this.draftPlacement(box); + this.draft = { + content: result.content ?? '', + mode, + sketchWorld, + labelsWorld, + sceneJson, + x: place.x, + y: place.y, + width: DRAFT_WIDTH, + }; + this.echoBaselineCount = this.strokes.length; + this.lassoPts = null; + this.lassoBBox = null; + } catch (err: any) { + let empty = /empty annotation/i.test( + err instanceof Error ? err.message : String(err), + ); + if (empty) { + // the marker looked and had nothing to add — ambient silence, not an error + this.dismissLasso(); + this.autoSuppressedBelow = this.strokes.length + AUTO_MIN_STROKES; + } else { + let place = this.draftPlacement(box); + this.echoError = { + message: friendlyEchoError(err), + mode, + x: place.x, + y: place.y, + }; + } + } finally { + this.thinkingLabel = null; + } + }); + + startEcho = (mode: string) => { + this.echoTask.perform(asEchoMode(mode)); + }; + + cancelEcho = () => { + this.echoTask.cancelAll(); + this.thinkingLabel = null; + }; + + retryEcho = () => { + let err = this.echoError; + if (!err || !this.lastEchoBBox) { + return; + } + // restore the capture region so the retry has a lasso to read + this.lassoBBox = this.lastEchoBBox; + if (!this.lassoPts) { + let b = this.lastEchoBBox; + this.lassoPts = [ + b.minX, + b.minY, + b.maxX, + b.minY, + b.maxX, + b.maxY, + b.minX, + b.maxY, + ]; + } + this.echoError = null; + this.echoTask.perform(err.mode); + }; + + // The title block is docked bottom-right in SCREEN space; a slip placed in + // world space can slide under it. Place to the right of the ink by default, + // flip left when that would overflow, and lift clear of the docked block. + draftPlacement(box: BBox): { x: number; y: number } { + let el = this.boardEl; + let x = box.maxX + 30; + let y = box.minY; + if (!el) { + return { x, y }; + } + let toScreenX = (wx: number) => wx * this.zoom + this.panX; + let toScreenY = (wy: number) => wy * this.zoom + this.panY; + let widthOnScreen = DRAFT_WIDTH * this.zoom; + if (toScreenX(x) + widthOnScreen > el.clientWidth - 16) { + x = box.minX - DRAFT_WIDTH - 30; // flip to the ink's left + } + let blockTop = el.clientHeight - TITLE_BLOCK_SCREEN.h; + let blockLeft = el.clientWidth - TITLE_BLOCK_SCREEN.w; + let overlapsBlock = + toScreenY(y) + DRAFT_MIN_SCREEN_H > blockTop && + toScreenX(x) + widthOnScreen > blockLeft; + if (overlapsBlock) { + y = (blockTop - DRAFT_MIN_SCREEN_H - 12 - this.panY) / this.zoom; + } + return { x, y }; + } + + captureRegion( + box: BBox, + lasso: number[], + ): { dataUrl: string; width: number; height: number } { + let margin = CROP_MARGIN; + let w = box.maxX - box.minX + margin * 2; + let h = box.maxY - box.minY + margin * 2; + let scale = Math.min(1, CROP_MAX_DIM / Math.max(w, h)) * 2; + let off = document.createElement('canvas'); + off.width = Math.max(1, Math.round(w * scale)); + off.height = Math.max(1, Math.round(h * scale)); + let ctx = off.getContext('2d'); + if (!ctx) { + throw new Error('Could not capture the board region.'); + } + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, off.width, off.height); + ctx.setTransform( + scale, + 0, + 0, + scale, + -(box.minX - margin) * scale, + -(box.minY - margin) * scale, + ); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = '#1a1a3a'; + let padded = expandBBox(box, margin); + for (let s of this.strokes) { + let sb = strokeBBox(s); + let overlaps = + sb.maxX >= padded.minX && + sb.minX <= padded.maxX && + sb.maxY >= padded.minY && + sb.minY <= padded.maxY; + // include everything the user circled, plus nearby context inside the margin + if (overlaps || strokeInLasso(s, lasso)) { + this.strokePath(ctx, s); + } + } + return { + dataUrl: off.toDataURL('image/png'), + width: off.width, + height: off.height, + }; + } + + // ---- draft / accepted echoes ---- + + get echoNotes() { + let list = this.args.model?.echoes ?? []; + return list.filter(Boolean).map((note, index) => { + let override = + this.noteDrag && this.noteDrag.index === index ? this.noteDrag : null; + let parsed = parseSketchJson(note.sketchJson); + return { + note, + index, + sketchOverlay: sketchOverlay( + parsed.polylines.length ? parsed.polylines : null, + parsed.labels, + ), + style: htmlSafe( + `left: ${override?.x ?? note.x ?? 0}px; top: ${ + override?.y ?? note.y ?? 0 + }px; width: ${note.width ?? DRAFT_WIDTH}px`, + ), + }; + }); + } + + get draftStyle(): SafeString { + let d = this.draft; + if (!d) { + return htmlSafe('display: none'); + } + return htmlSafe(`left: ${d.x}px; top: ${d.y}px; width: ${d.width}px`); + } + + get errorStyle(): SafeString { + let s = this.echoError; + if (!s) { + return htmlSafe('display: none'); + } + return htmlSafe(`left: ${s.x}px; top: ${s.y}px; width: ${DRAFT_WIDTH}px`); + } + + get draftSketchOverlay(): { + style: SafeString; + viewBox: string; + paths: string[]; + texts: EchoLabel[]; + } | null { + return sketchOverlay( + this.draft?.sketchWorld ?? null, + this.draft?.labelsWorld ?? [], + ); + } + + get isEmpty(): boolean { + return ( + this.strokeCount === 0 && + (this.args.model?.echoes ?? []).length === 0 && + !this.draft + ); + } + + markerInstructions: string | null | undefined; + + // The marker's prose lives in the bundled Skill card so it can be read and + // remixed as data. Only the component can reach the store (a plain realm + // Command cannot), so the board resolves it and passes it to the command. + async loadMarkerInstructions(): Promise { + if (this.markerInstructions !== undefined) { + return this.markerInstructions ?? ''; + } + this.markerInstructions = null; + try { + // @ts-expect-error import.meta is valid ESM but TS reads .gts as CJS + let here: string = import.meta.url; + let url = new URL('../Skill/echo-marker', here).href; + let skill: any = await this.args.context?.store?.get?.(url); + let text = skill?.instructions; + if (typeof text === 'string' && text.trim().length > 40) { + this.markerInstructions = text.trim(); + } + } catch { + // unreachable skill (remixed realm, offline) — the local grammar stands in + } + return this.markerInstructions ?? ''; + } + + // a linked Theme card means the host has injected semantic tokens into this + // card's scope; only then may our tokens defer to them (otherwise the host's + // default palette would erase the board's own identity) + get themed(): boolean { + return Boolean((this.args.model as any)?.cardInfo?.theme); + } + + get lastInkLabel(): string { + let notes = (this.args.model?.echoes ?? []).filter(Boolean); + let latest = notes[notes.length - 1]?.acceptedAt; + if (!latest) { + return '—'; + } + try { + return new Date(latest as unknown as string).toLocaleDateString( + undefined, + { + month: 'short', + day: 'numeric', + }, + ); + } catch { + return '—'; + } + } + + get boardLabel(): string { + let echoes = (this.args.model?.echoes ?? []).length; + return `Whiteboard with ${this.strokeCount} ink strokes and ${echoes} AI notes`; + } + + acceptDraft = () => { + let d = this.draft; + let model = this.args.model; + if (!d || !model) { + return; + } + this.commitBoardState(); + let existing = (model.echoes ?? []).filter(Boolean); + let note = new EchoNoteField({ + label: `Echo № ${existing.length + 1}`, + mode: d.mode, + content: d.content, + sketchJson: + d.sketchWorld || d.labelsWorld?.length + ? JSON.stringify({ + polylines: d.sketchWorld ?? [], + labels: d.labelsWorld ?? [], + }) + : undefined, + sceneJson: d.sceneJson || undefined, + x: Math.round(d.x), + y: Math.round(d.y), + width: d.width, + acceptedAt: new Date(), + }); + model.echoes = [...existing, note]; + this.draft = null; + }; + + dismissDraft = () => { + this.draft = null; + }; + + startDraftDrag = (event: Event) => { + let e = event as PointerEvent; + e.stopPropagation(); + let el = e.currentTarget as HTMLElement; + try { + el.setPointerCapture(e.pointerId); + } catch { + // see onPointerDown + } + this.activePointer = { + kind: 'drag-draft', + id: e.pointerId, + lastX: e.clientX, + lastY: e.clientY, + }; + }; + + startNoteDrag = (index: number, event: Event) => { + let e = event as PointerEvent; + e.stopPropagation(); + let el = e.currentTarget as HTMLElement; + try { + el.setPointerCapture(e.pointerId); + } catch { + // see onPointerDown + } + this.activePointer = { + kind: 'drag-note', + id: e.pointerId, + lastX: e.clientX, + lastY: e.clientY, + noteIndex: index, + }; + }; + + commitNotePosition(drag: { index: number; x: number; y: number }) { + let model = this.args.model; + if (!model) { + return; + } + let notes = (model.echoes ?? []).filter(Boolean); + let target = notes[drag.index]; + if (!target) { + return; + } + this.commitBoardState(); + target.x = Math.round(drag.x); + target.y = Math.round(drag.y); + model.echoes = [...notes]; + } + + removeNote = (index: number) => { + let model = this.args.model; + if (!model) { + return; + } + let notes = (model.echoes ?? []).filter(Boolean); + let removed = notes[index]; + if (!removed) { + return; + } + this.commitBoardState(); + model.echoes = notes.filter((_, i) => i !== index); + let label = removed.label ?? 'Echo'; + this.toast = { + message: `${label} removed`, + undo: () => { + let current = (model.echoes ?? []).filter(Boolean); + let restored = [...current]; + restored.splice(Math.min(index, restored.length), 0, removed); + model.echoes = restored; + this.toast = null; + window.clearTimeout(this.toastTimer); + }, + }; + window.clearTimeout(this.toastTimer); + this.toastTimer = window.setTimeout(() => { + this.toast = null; + }, 6000); + }; + + undoToast = () => { + this.toast?.undo(); + }; + + stopEvent = (e: Event) => { + e.stopPropagation(); + }; + + echoModes = ECHO_MODES; + + +} + +function sketchOverlay( + polylines: number[][] | null, + labels: EchoLabel[] = [], +): { + style: SafeString; + viewBox: string; + paths: string[]; + texts: EchoLabel[]; +} | null { + let lineBox = polylines?.length ? polylinesBBox(polylines) : null; + let boxes: BBox[] = lineBox ? [lineBox] : []; + for (let l of labels) { + boxes.push({ + minX: l.x, + minY: l.y, + maxX: l.x + l.size * Math.max(1, l.text.length) * 0.62, + maxY: l.y + l.size * 1.25, + }); + } + let box = unionBBox(boxes); + if (!box) { + return null; + } + let pad = 6; + let w = Math.max(1, box.maxX - box.minX + pad * 2); + let h = Math.max(1, box.maxY - box.minY + pad * 2); + return { + style: htmlSafe( + `left: ${box.minX - pad}px; top: ${box.minY - pad}px; width: ${w}px; height: ${h}px`, + ), + viewBox: `${box.minX - pad} ${box.minY - pad} ${w} ${h}`, + paths: polylines?.length ? polylinesToPaths(JSON.stringify(polylines)) : [], + texts: labels, + }; +} + +function expandBBox(box: BBox, margin: number): BBox { + return { + minX: box.minX - margin, + minY: box.minY - margin, + maxX: box.maxX + margin, + maxY: box.maxY + margin, + }; +} + +function velocityWidth(base: number, dist: number): number { + return base * clamp(1.45 - dist / 28, 0.6, 1.35); +} + +function friendlyEchoError(err: unknown): string { + let raw = err instanceof Error ? err.message : String(err); + if (/402|credit/i.test(raw)) { + return "You're out of AI credits."; + } + if (/timeout|network|fetch/i.test(raw)) { + return 'The request did not make it through — check your connection and retry.'; + } + if (/empty annotation|could not read/i.test(raw)) { + return "I couldn't read that — try circling a little more context."; + } + return "I couldn't read that — try circling a little more context."; +} diff --git a/f22ed9-echo-pad/echo-pad/components/scene-player.gts b/f22ed9-echo-pad/echo-pad/components/scene-player.gts new file mode 100644 index 00000000..df99f5c3 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/components/scene-player.gts @@ -0,0 +1,496 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { modifier } from 'ember-modifier'; + +import PlayIcon from '@cardstack/boxel-icons/play'; +import { eq } from '@cardstack/boxel-ui/helpers'; + +import { + compileExpression, + validateSceneSpec, + type SceneAddStep, + type ScenePlayStep, + type SceneSpec, +} from '../utils/scene'; + +type ManimRuntime = Record; + +// only one scene plays at a time — a board full of projection slips must not +// hold a WebGL context per slip +let stopCurrent: (() => void) | null = null; + +const SCREEN_BG = '#0b0d12'; +const SCREEN_ACCENT = '#c8ff3d'; +const PAPER_BG = '#f7f4ec'; +const PAPER_ACCENT = '#c33d2e'; +const INK_BLUE = '#2b3f8c'; + +async function runScene( + M: ManimRuntime, + scene: { + add(...o: unknown[]): void; + play(...a: unknown[]): Promise; + wait(d?: number): Promise; + }, + spec: SceneSpec, +): Promise { + let objects = new Map(); + let introduced = new Set(); + + let accent = spec.background === 'paper' ? PAPER_ACCENT : SCREEN_ACCENT; + let build = (step: SceneAddStep): any => { + let color = + step.color === 'accent' || !step.color ? accent : (step.color as string); + switch (step.add) { + case 'ink': { + let lines = spec.ink; + if (!Array.isArray(lines) || !lines.length) { + return null; + } + let parts = lines + .map((l) => { + let pts: number[][] = []; + for (let i = 0; i + 1 < l.length; i += 2) { + pts.push([l[i], l[i + 1], 0]); + } + if (pts.length < 2) { + return null; + } + let v = new M.VMobject(); + v.setPointsAsCorners(pts); + // hand ink is a stroke, never a filled shape + (v as any).fillOpacity = 0; + (v as any).strokeWidth = 3; + return v; + }) + .filter(Boolean); + if (!parts.length) { + return null; + } + let g = new M.VGroup(...parts); + g.setColor?.( + step.color && step.color !== 'accent' + ? (step.color as string) + : INK_BLUE, + ); + return g; + } + case 'axes': + return new M.Axes({ + xRange: [...(step.xRange as number[]), 1], + yRange: [...(step.yRange as number[]), 1], + xLength: 10, + yLength: 5.5, + color: '#667080', + tips: false, + }); + case 'plane': + return new M.NumberPlane({ + xRange: step.xRange, + yRange: step.yRange, + xLength: 10, + yLength: 6, + color: '#596170', + backgroundLineStyle: { + color: '#303541', + opacity: 0.6, + strokeWidth: 1, + }, + }); + case 'graph': { + let fn = compileExpression(String(step.fn)); + if (!fn) { + return null; + } + let axes = step.axes ? objects.get(String(step.axes)) : undefined; + return new M.FunctionGraph({ + func: fn, + xRange: step.xRange, + color, + ...(axes ? { axes } : {}), + }); + } + case 'circle': + return new M.Circle({ + radius: step.radius, + color, + strokeWidth: 3, + fillOpacity: 0.1, + }); + case 'square': + return new M.Square({ + sideLength: step.side, + color, + fillOpacity: 0.12, + }); + case 'rect': + return new M.Rectangle({ + width: step.w, + height: step.h, + color, + fillOpacity: 0.12, + }); + case 'line': + return new M.Line({ + start: [...(step.from as number[]), 0], + end: [...(step.to as number[]), 0], + color, + }); + case 'arrow': + return new M.Arrow({ + start: [...(step.from as number[]), 0], + end: [...(step.to as number[]), 0], + color, + }); + case 'vector': + return new M.Vector({ + direction: [...(step.to as number[]), 0], + color, + }); + case 'dot': + return new M.Dot({ + point: [...(step.at as number[]), 0], + radius: step.radius, + color, + }); + case 'text': { + let t = new M.Text(String(step.value)); + t.setColor?.(color); + return t; + } + case 'mathtex': { + let t = new M.MathTex({ latex: String(step.value) }); + t.setColor?.(color); + return t; + } + default: + return null; + } + }; + + for (let step of spec.steps) { + if ('add' in step && typeof step.add === 'string') { + let s = step as SceneAddStep; + try { + let obj = build(s); + if (!obj) { + continue; + } + if (s.at && s.add !== 'dot') { + obj.moveTo?.([...(s.at as number[]), 0]); + } + objects.set(s.id, obj); + } catch { + // one bad mobject never kills the scene + } + continue; + } + let p = step as ScenePlayStep; + let d = Number(p.duration) || 1; + if (p.play === 'wait') { + await scene.wait(d); + continue; + } + let obj = p.target ? objects.get(p.target) : undefined; + if (!obj) { + continue; + } + try { + let intro = ['create', 'write', 'fadeIn'].includes(p.play); + if (!intro && !introduced.has(p.target!)) { + scene.add(obj); + introduced.add(p.target!); + } + switch (p.play) { + case 'create': + await scene.play(new M.Create(obj, { duration: d })); + break; + case 'write': + await scene.play(new M.Write(obj, { duration: d })); + break; + case 'fadeIn': + await scene.play(new M.FadeIn(obj, { duration: d })); + break; + case 'fadeOut': + await scene.play(new M.FadeOut(obj, { duration: d })); + break; + case 'transform': { + let to = objects.get(String(p.to)); + if (to) { + await scene.play(new M.Transform(obj, to, { duration: d })); + } + break; + } + case 'rotate': + await scene.play(new M.Rotate(obj, { angle: p.angle, duration: d })); + break; + case 'scale': + await scene.play( + new M.Scale(obj, { scaleFactor: p.factor, duration: d }), + ); + break; + case 'wave': + await scene.play( + new M.ApplyWave(obj, { + amplitude: p.amplitude, + duration: d, + direction: 'vertical', + }), + ); + break; + case 'orbit': { + let path = objects.get(String(p.path)); + if (path) { + await scene.play(new M.MoveAlongPath(obj, { path, duration: d })); + } + break; + } + case 'indicate': + await scene.play(new M.Indicate(obj, { duration: d })); + break; + case 'shear': { + let m2 = p.matrix as number[][]; + await scene.play( + new M.ApplyMatrix(obj, { + matrix: [ + [m2[0][0], m2[0][1], 0], + [m2[1][0], m2[1][1], 0], + [0, 0, 1], + ], + duration: d, + }), + ); + break; + } + } + if (intro) { + introduced.add(p.target!); + } + } catch { + // skip a failing step; the rest of the scene still plays + } + } +} + +interface Signature { + Args: { sceneJson?: string | null }; + Element: HTMLElement; +} + +export class EchoScenePlayer extends Component { + @tracked status: 'poster' | 'active' | 'error' = 'poster'; + + get spec(): SceneSpec | null { + try { + return validateSceneSpec(JSON.parse(this.args.sceneJson ?? '')); + } catch { + return null; + } + } + + get title(): string { + return this.spec?.title ?? 'Animation'; + } + + get background(): string { + return this.spec?.background ?? 'screen'; + } + + stopEvent = (e: Event) => { + e.stopPropagation(); + }; + + play = () => { + if (!this.spec) { + this.status = 'error'; + return; + } + stopCurrent?.(); + this.status = 'active'; + }; + + stop = () => { + this.status = 'poster'; + }; + + willDestroy(): void { + super.willDestroy(); + if (stopCurrent === this.stop) { + stopCurrent = null; + } + } + + mountPlayer = modifier((element: HTMLElement) => { + let active = true; + let player: { dispose(): void } | undefined; + stopCurrent = this.stop; + + void (async () => { + try { + // lazy: ink-only boards never pay for the manim bundle + // @ts-expect-error the pinned realm bundle ships as untyped browser JS + let M = (await import('../manim-web')) as unknown as ManimRuntime; + if (!active) { + return; + } + player = new M.Player(element, { + autoHideMs: 2600, + autoPlay: true, + loop: false, + backgroundColor: + this.spec!.background === 'paper' ? PAPER_BG : SCREEN_BG, + }); + await (player as any).sequence(async (scene: any) => + runScene(M, scene, this.spec!), + ); + } catch { + if (active) { + this.status = 'error'; + } + } + })(); + + return () => { + active = false; + player?.dispose(); + element.replaceChildren(); + if (stopCurrent === this.stop) { + stopCurrent = null; + } + }; + }); + + +} diff --git a/f22ed9-echo-pad/echo-pad/echo-pad.gts b/f22ed9-echo-pad/echo-pad/echo-pad.gts new file mode 100644 index 00000000..6b336822 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/echo-pad.gts @@ -0,0 +1,42 @@ +import { + CardDef, + contains, + containsMany, + field, +} from '@cardstack/base/card-api'; +import StringField from '@cardstack/base/string'; +import TextAreaField from '@cardstack/base/text-area'; +import PencilRulerIcon from '@cardstack/boxel-icons/pencil-ruler'; + +import { EchoPadEdit } from './components/edit'; +import { EchoPadEmbedded, EchoPadFitted } from './components/formats'; +import { EchoPadIsolated } from './components/isolated'; +import { EchoNoteField } from './fields/echo-note'; + +export { EchoNoteField }; + +export class EchoPad extends CardDef { + static displayName = 'Echo Pad'; + static icon = PencilRulerIcon; + static prefersWideFormat = true; + + @field boardName = contains(StringField); + // serialized ink strokes ({v, strokes: [{w, pts}]}) — one field, written on + // a debounced idle, never per point + @field inkJson = contains(TextAreaField); + // camera ({x, y, zoom}) so a board reopens where it was left + @field viewportJson = contains(StringField); + // accepted AI annotations; unaccepted drafts never reach the model + @field echoes = containsMany(EchoNoteField); + + @field title = contains(StringField, { + computeVia: function (this: EchoPad) { + return this.boardName?.trim() || 'Echo Pad'; + }, + }); +} + +EchoPad.isolated = EchoPadIsolated; +EchoPad.embedded = EchoPadEmbedded; +EchoPad.fitted = EchoPadFitted; +EchoPad.edit = EchoPadEdit; diff --git a/f22ed9-echo-pad/echo-pad/fields/echo-note.gts b/f22ed9-echo-pad/echo-pad/fields/echo-note.gts new file mode 100644 index 00000000..cd0665a4 --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/fields/echo-note.gts @@ -0,0 +1,59 @@ +import { FieldDef, Component, contains, field } from '@cardstack/base/card-api'; +import DateTimeField from '@cardstack/base/datetime'; +import NumberField from '@cardstack/base/number'; +import StringField from '@cardstack/base/string'; +import TextAreaField from '@cardstack/base/text-area'; +import SparklesIcon from '@cardstack/boxel-icons/sparkles'; + +export class EchoNoteField extends FieldDef { + static displayName = 'Echo Note'; + static icon = SparklesIcon; + + @field label = contains(StringField); // e.g. "Echo № 2" + @field mode = contains(StringField); // solve | complete | explain + @field content = contains(TextAreaField); + @field x = contains(NumberField); // world coordinates on the board + @field y = contains(NumberField); + @field width = contains(NumberField); + @field acceptedAt = contains(DateTimeField); + @field sketchJson = contains(StringField); // red-pen sketch the AI drew back (world polylines) + @field sceneJson = contains(StringField); // manim scene spec (validated, deterministic) + + static embedded = class Embedded extends Component { + + }; +} diff --git a/f22ed9-echo-pad/echo-pad/utils/index.gts b/f22ed9-echo-pad/echo-pad/utils/index.gts new file mode 100644 index 00000000..716ff1bd --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/utils/index.gts @@ -0,0 +1,490 @@ +// Pure ink-model, geometry, and echo-validation helpers. No card imports — +// keeps the module testable and lets the command's validation core run as +// plain functions. + +import { extractSceneBlock, SCENE_GRAMMAR, type SceneSpec } from './scene'; + +export type InkStroke = { + w: number; // base stroke width in world units + pts: number[]; // flat [x0, y0, x1, y1, ...] in world coordinates +}; + +export type InkDoc = { + v: 1; + strokes: InkStroke[]; +}; + +export type BBox = { + minX: number; + minY: number; + maxX: number; + maxY: number; +}; + +export type EchoModeKey = + | 'solve' + | 'check' + | 'complete' + | 'explain' + | 'animate' + | 'auto'; + +export const ECHO_MODES: { key: EchoModeKey; label: string; ask: string }[] = [ + { + key: 'solve', + label: 'Solve', + ask: 'Solve the problem shown in the handwriting. Show the key steps briefly, then the final answer.', + }, + { + key: 'check', + label: 'Check', + ask: "The handwriting contains the user's own worked solution or claim. Mark it like a kind but precise grader: if it is correct, reply with a short confirmation ending in ✓; if it is wrong, name the first incorrect step and give the corrected result ending in ✗ → the right answer.", + }, + { + key: 'complete', + label: 'Complete', + ask: 'The sketch or working is unfinished. Infer the intent and supply the missing part.', + }, + { + key: 'explain', + label: 'Explain', + ask: 'Explain what the handwriting and sketch describe, concisely, as an annotation.', + }, + { + key: 'animate', + label: 'Animate', + ask: 'Compose a short animation that shows the idea in motion. Strongly prefer a scene block; keep the annotation text to at most 2 short lines.', + }, +]; + +// 'auto' has no ECHO_MODES picker entry — the board runs it ambiently. +export function asEchoMode(v: unknown): EchoModeKey { + return typeof v === 'string' && + (v === 'auto' || ECHO_MODES.some((m) => m.key === v)) + ? (v as EchoModeKey) + : 'solve'; +} + +export const INK_COLOR = '#2b3f8c'; +export const ECHO_COLOR = '#c33d2e'; +export const ZOOM_MIN = 0.25; +export const ZOOM_MAX = 4; +export const ECHO_MAX_CHARS = 1200; + +export const EMPTY_INK: InkDoc = { v: 1, strokes: [] }; + +export function serializeInk(doc: InkDoc): string { + return JSON.stringify({ + v: 1, + strokes: doc.strokes.map((s) => ({ + w: round1(s.w), + pts: s.pts.map(round1), + })), + }); +} + +export function parseInk(json: string | undefined | null): InkDoc { + if (!json) { + return { v: 1, strokes: [] }; + } + try { + let raw = JSON.parse(json); + let strokes = Array.isArray(raw?.strokes) ? raw.strokes : []; + return { + v: 1, + strokes: strokes + .filter( + (s: any) => + s && + Array.isArray(s.pts) && + s.pts.length >= 4 && + s.pts.length % 2 === 0, + ) + .map((s: any) => ({ + w: clamp(Number(s.w) || 2.5, 0.5, 24), + pts: s.pts.map((n: any) => Number(n) || 0), + })), + }; + } catch { + return { v: 1, strokes: [] }; + } +} + +export function strokeBBox(stroke: InkStroke): BBox { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (let i = 0; i < stroke.pts.length; i += 2) { + let x = stroke.pts[i], + y = stroke.pts[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + return { minX, minY, maxX, maxY }; +} + +export function unionBBox(boxes: BBox[]): BBox | null { + if (!boxes.length) { + return null; + } + return boxes.reduce((a, b) => ({ + minX: Math.min(a.minX, b.minX), + minY: Math.min(a.minY, b.minY), + maxX: Math.max(a.maxX, b.maxX), + maxY: Math.max(a.maxY, b.maxY), + })); +} + +export function pointInPolygon(x: number, y: number, poly: number[]): boolean { + // poly is a flat [x0, y0, x1, y1, ...] ring + let inside = false; + let n = poly.length / 2; + for (let i = 0, j = n - 1; i < n; j = i++) { + let xi = poly[i * 2], + yi = poly[i * 2 + 1]; + let xj = poly[j * 2], + yj = poly[j * 2 + 1]; + let intersects = + yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersects) { + inside = !inside; + } + } + return inside; +} + +export function strokeInLasso(stroke: InkStroke, lasso: number[]): boolean { + // A stroke counts as captured when most of its points fall inside the ring. + let total = stroke.pts.length / 2; + if (total === 0 || lasso.length < 6) { + return false; + } + let inside = 0; + for (let i = 0; i < stroke.pts.length; i += 2) { + if (pointInPolygon(stroke.pts[i], stroke.pts[i + 1], lasso)) { + inside++; + } + } + return inside / total >= 0.5; +} + +export function strokeNearPoint( + stroke: InkStroke, + x: number, + y: number, + radius: number, +): boolean { + let r2 = radius * radius; + for (let i = 0; i + 3 < stroke.pts.length; i += 2) { + if ( + segmentDistSq( + x, + y, + stroke.pts[i], + stroke.pts[i + 1], + stroke.pts[i + 2], + stroke.pts[i + 3], + ) <= r2 + ) { + return true; + } + } + return false; +} + +function segmentDistSq( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +): number { + let dx = bx - ax, + dy = by - ay; + let lenSq = dx * dx + dy * dy; + let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq; + t = clamp(t, 0, 1); + let cx = ax + t * dx, + cy = ay + t * dy; + return (px - cx) * (px - cx) + (py - cy) * (py - cy); +} + +export function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +const AUTO_ASK = + 'Respond helpfully to whatever the ink is reaching for: solve a problem, answer a question, complete a sketch or list, or briefly explain — whichever fits. If nothing clearly calls for a response, reply with exactly NOTHING.'; + +const DRAW_HINT = + ' If a small sketch genuinely helps (completing a diagram, drawing the missing part, an arrow, a quick plot), append one fenced block exactly like ```draw {"polylines":[[x1,y1,x2,y2,...]]}``` — coordinates are PIXELS in the image you were shown (0,0 = its top-left), so your lines land exactly where you place them on the drawing. At most 6 polylines. NEVER form characters out of polylines — to write numbers, words or expressions on the paper, add "write" entries to the same block: {"polylines":[...],"write":[{"text":"14","at":[x,y],"size":44}]} — at is the top-left of the text in image pixels, size its height in pixels; the board renders it in red handwriting exactly there. Use polylines only for shapes, arrows and plots.'; + +export function buildEchoPrompt(mode: EchoModeKey, boardName?: string): string { + let ask = + mode === 'auto' + ? AUTO_ASK + : (ECHO_MODES.find((x) => x.key === mode) ?? ECHO_MODES[0]).ask; + return [ + 'You are a red pen annotating a handwritten board', + boardName ? ` titled "${boardName}"` : '', + '. The image shows handwriting and rough sketches — it could be math, a question in words, a diagram, a list, or a half-formed idea. ', + ask, + ' Reply with the annotation text ONLY — no preamble, no markdown fences, no headings.', + ' If math is involved, write it in plain unicode (v₀, θ, ², ×, ÷, ≈, √), never LaTeX.', + ' Keep it under 8 short lines, sized for a margin note.', + DRAW_HINT, + mode === 'animate' || mode === 'auto' ? ' ' + SCENE_GRAMMAR : '', + ] + .filter(Boolean) + .join(''); +} + +export function parseEchoResponse( + raw: string | undefined | null, +): string | null { + if (!raw) { + return null; + } + let text = String(raw).trim(); + // strip a wrapping code fence if the model ignored instructions + let fence = text.match(/^```[a-zA-Z]*\n([\s\S]*?)\n?```$/); + if (fence) { + text = fence[1].trim(); + } + // drop common chat preambles on the first line + text = text.replace(/^(sure|certainly|here('|')s)[^\n]*\n+/i, '').trim(); + if (!text) { + return null; + } + if (text.length > ECHO_MAX_CHARS) { + text = text.slice(0, ECHO_MAX_CHARS).trimEnd() + '…'; + } + return text; +} + +export const MAX_SKETCH_POLYLINES = 6; +const MAX_SKETCH_POINTS = 64; +export const MAX_SKETCH_LABELS = 4; +const MAX_LABEL_CHARS = 32; + +// Models cannot hand-write legible characters out of raw polylines, so text +// answers travel as write-labels: the model picks the words and the spot, the +// board renders them in its own red handwriting. +export type EchoLabel = { + text: string; + x: number; + y: number; + size: number; // text height, same coordinate space as x/y +}; + +export type EchoSplit = { + text: string | null; + polylines: number[][] | null; // image-space, flat [x, y, ...] per line + labels: EchoLabel[] | null; // image-space write-backs + scene: SceneSpec | null; // validated manim scene spec, if the model composed one +}; + +export function splitEchoResponse(raw: string | undefined | null): EchoSplit { + if (!raw) { + return { text: null, polylines: null, labels: null, scene: null }; + } + let extracted = extractSceneBlock(String(raw)); + let text = extracted.rest; + let scene = extracted.spec; + let polylines: number[][] | null = null; + let labels: EchoLabel[] | null = null; + let m = text.match(/```draw\s*([\s\S]*?)```/); + if (m) { + text = text.replace(m[0], '').trim(); + try { + let spec = JSON.parse(m[1]); + let lines = Array.isArray(spec?.polylines) ? spec.polylines : []; + let valid = lines + .filter( + (l: any) => + Array.isArray(l) && + l.length >= 4 && + l.length % 2 === 0 && + l.every((n: any) => typeof n === 'number' && isFinite(n)), + ) + .slice(0, MAX_SKETCH_POLYLINES) + .map((l: number[]) => + l.slice(0, MAX_SKETCH_POINTS * 2).map((n) => clamp(n, 0, 8192)), + ); + if (valid.length) { + polylines = valid; + } + let writes = Array.isArray(spec?.write) ? spec.write : []; + let validLabels = writes + .filter( + (w: any) => + w && + typeof w.text === 'string' && + w.text.trim().length && + Array.isArray(w.at) && + w.at.length >= 2 && + isFinite(Number(w.at[0])) && + isFinite(Number(w.at[1])), + ) + .slice(0, MAX_SKETCH_LABELS) + .map((w: any) => ({ + text: w.text.replace(/\s+/g, ' ').trim().slice(0, MAX_LABEL_CHARS), + x: clamp(Number(w.at[0]), 0, 8192), + y: clamp(Number(w.at[1]), 0, 8192), + size: clamp(Number(w.size) || 44, 10, 240), + })); + if (validLabels.length) { + labels = validLabels; + } + } catch { + // malformed draw block: keep the text, drop the sketch + } + } + let cleaned = parseEchoResponse(text); + if (cleaned && /^nothing[.!]?$/i.test(cleaned)) { + cleaned = null; // the auto mode's explicit "no response needed" signal + } + return { text: cleaned, polylines, labels, scene }; +} + +// sketchJson carries either the legacy bare polylines array or the current +// { polylines, labels } object — readers accept both. +export function parseSketchJson(json: string | undefined | null): { + polylines: number[][]; + labels: EchoLabel[]; +} { + if (!json) { + return { polylines: [], labels: [] }; + } + try { + let raw = JSON.parse(json); + if (Array.isArray(raw)) { + return { + polylines: raw.filter((l: any) => Array.isArray(l)), + labels: [], + }; + } + return { + polylines: Array.isArray(raw?.polylines) + ? raw.polylines.filter((l: any) => Array.isArray(l)) + : [], + labels: Array.isArray(raw?.labels) + ? raw.labels.filter( + (w: any) => + w && typeof w.text === 'string' && isFinite(w.x) && isFinite(w.y), + ) + : [], + }; + } catch { + return { polylines: [], labels: [] }; + } +} + +export function polylinesToPaths(json: string | undefined | null): string[] { + return parseSketchJson(json) + .polylines.filter((l) => l.length >= 4) + .map((l) => { + let d = `M ${l[0]} ${l[1]}`; + for (let i = 2; i < l.length; i += 2) { + d += ` L ${l[i]} ${l[i + 1]}`; + } + return d; + }); +} + +export function labelsFromSketchJson( + json: string | undefined | null, +): EchoLabel[] { + return parseSketchJson(json).labels; +} + +export function mapLabelsToWorld( + labels: EchoLabel[], + allCoords: number[], + box: BBox, + margin: number, + imageW: number, + imageH: number, +): EchoLabel[] { + let x0 = box.minX - margin; + let y0 = box.minY - margin; + let w = box.maxX - box.minX + margin * 2; + let h = box.maxY - box.minY + margin * 2; + let maxCoord = Math.max(...allCoords, 0); + let basisX = maxCoord <= 105 ? 100 : Math.max(1, imageW); + let basisY = maxCoord <= 105 ? 100 : Math.max(1, imageH); + return labels.map((l) => ({ + text: l.text, + x: x0 + (l.x / basisX) * w, + y: y0 + (l.y / basisY) * h, + size: (l.size / basisY) * h, + })); +} + +export function mapPolylinesToWorld( + polylines: number[][], + box: BBox, + margin: number, + imageW: number, + imageH: number, + basisCoords?: number[], +): number[][] { + // the capture image spans box ± margin in world units. Models are asked for + // image-pixel coordinates, but some reply normalized 0–100 — detect which. + let x0 = box.minX - margin; + let y0 = box.minY - margin; + let w = box.maxX - box.minX + margin * 2; + let h = box.maxY - box.minY + margin * 2; + let maxCoord = Math.max(...(basisCoords ?? polylines.flat()), 0); + let basisX = maxCoord <= 105 ? 100 : Math.max(1, imageW); + let basisY = maxCoord <= 105 ? 100 : Math.max(1, imageH); + return polylines.map((l) => + l.map((n, i) => + i % 2 === 0 ? x0 + (n / basisX) * w : y0 + (n / basisY) * h, + ), + ); +} + +export function polylinesBBox(polylines: number[][]): BBox | null { + let pts = polylines.flat(); + if (pts.length < 4) { + return null; + } + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (let i = 0; i < pts.length; i += 2) { + if (pts[i] < minX) minX = pts[i]; + if (pts[i] > maxX) maxX = pts[i]; + if (pts[i + 1] < minY) minY = pts[i + 1]; + if (pts[i + 1] > maxY) maxY = pts[i + 1]; + } + return { minX, minY, maxX, maxY }; +} + +// A cheap deterministic key over the capture + mode. Not cryptographic — it +// only needs to distinguish one circled region from another within a session. +export function echoCacheKey( + imageDataUrl: string, + mode: string, + promptSource = '', +): string { + let h1 = 0x811c9dc5; + let h2 = 0x01000193; + let src = imageDataUrl + '\u0000' + promptSource; + for (let i = 0; i < src.length; i++) { + let c = src.charCodeAt(i); + h1 = ((h1 ^ c) * 0x01000193) >>> 0; + h2 = ((h2 + c) * 0x85ebca6b) >>> 0; + } + return `${mode}:${src.length}:${h1.toString(36)}${h2.toString(36)}`; +} diff --git a/f22ed9-echo-pad/echo-pad/utils/scene.gts b/f22ed9-echo-pad/echo-pad/utils/scene.gts new file mode 100644 index 00000000..f5be78bc --- /dev/null +++ b/f22ed9-echo-pad/echo-pad/utils/scene.gts @@ -0,0 +1,518 @@ +// Pure scene-spec machinery: the whitelisted grammar the model writes, +// the validator that normalizes/rejects specs, and a safe math-expression +// parser for function graphs. The model NEVER supplies executable code — +// everything here is data, deterministically interpreted. + +import { clamp, type BBox, type InkStroke } from './index'; + +export const SCENE_MAX_STEPS = 16; +export const SCENE_MAX_DURATION = 4; + +export type SceneAddStep = { + add: string; + id: string; + [key: string]: unknown; +}; + +export type ScenePlayStep = { + play: string; + target?: string; + [key: string]: unknown; +}; + +export type SceneStep = SceneAddStep | ScenePlayStep; + +export type SceneSpec = { + title: string; + steps: SceneStep[]; + background: 'paper' | 'screen'; + ink?: number[][]; // the user's circled strokes in manim coords — injected by the board, never by the model +}; + +// ---- safe math expressions (recursive descent, no eval) ---- + +type Tok = + | { kind: 'num'; value: number } + | { kind: 'name'; value: string } + | { kind: 'op'; value: string }; + +const FN_NAMES = ['sin', 'cos', 'tan', 'exp', 'log', 'sqrt', 'abs'] as const; +const FNS: Record number> = { + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + exp: Math.exp, + log: Math.log, + sqrt: Math.sqrt, + abs: Math.abs, +}; + +function tokenize(src: string): Tok[] | null { + let toks: Tok[] = []; + let i = 0; + while (i < src.length) { + let c = src[i]; + if (c === ' ' || c === '\t') { + i++; + continue; + } + if (/[0-9.]/.test(c)) { + let j = i; + while (j < src.length && /[0-9.]/.test(src[j])) j++; + let n = Number(src.slice(i, j)); + if (!isFinite(n)) return null; + toks.push({ kind: 'num', value: n }); + i = j; + continue; + } + if (/[a-zA-Z]/.test(c)) { + let j = i; + while (j < src.length && /[a-zA-Z]/.test(src[j])) j++; + toks.push({ kind: 'name', value: src.slice(i, j).toLowerCase() }); + i = j; + continue; + } + if ('+-*/^()'.includes(c)) { + toks.push({ kind: 'op', value: c }); + i++; + continue; + } + return null; // anything else is rejected outright + } + return toks; +} + +type Evaluator = (x: number) => number; + +// grammar: expr → term ((+|-) term)* ; term → unary ((*|/|implicit) unary)* ; +// unary → -unary | power ; power → primary (^ unary)? ; +// primary → num | x | pi | e | fn(expr) | (expr) +export function compileExpression(src: string): Evaluator | null { + let toks = tokenize(src ?? ''); + if (!toks || !toks.length || toks.length > 64) { + return null; + } + let pos = 0; + let peek = () => toks![pos]; + let take = () => toks![pos++]; + let failed = false; + + function primary(): Evaluator { + let t = take(); + if (!t) { + failed = true; + return () => 0; + } + if (t.kind === 'num') { + let v = t.value; + return () => v; + } + if (t.kind === 'name') { + if (t.value === 'x') return (x) => x; + if (t.value === 'pi') return () => Math.PI; + if (t.value === 'e') return () => Math.E; + if ((FN_NAMES as readonly string[]).includes(t.value)) { + let fn = FNS[t.value]; + let open = take(); + if (!open || open.kind !== 'op' || open.value !== '(') { + failed = true; + return () => 0; + } + let inner = expr(); + let close = take(); + if (!close || close.kind !== 'op' || close.value !== ')') { + failed = true; + return () => 0; + } + return (x) => fn(inner(x)); + } + failed = true; + return () => 0; + } + if (t.kind === 'op' && t.value === '(') { + let inner = expr(); + let close = take(); + if (!close || close.kind !== 'op' || close.value !== ')') { + failed = true; + return () => 0; + } + return inner; + } + failed = true; + return () => 0; + } + + function unary(): Evaluator { + let t = peek(); + if (t && t.kind === 'op' && t.value === '-') { + take(); + let inner = unary(); + return (x) => -inner(x); + } + return power(); + } + + function power(): Evaluator { + let base = primary(); + let t = peek(); + if (t && t.kind === 'op' && t.value === '^') { + take(); + let exp = unary(); + return (x) => Math.pow(base(x), exp(x)); + } + return base; + } + + function startsPrimary(t: Tok | undefined): boolean { + return ( + !!t && + (t.kind === 'num' || + t.kind === 'name' || + (t.kind === 'op' && t.value === '(')) + ); + } + + function term(): Evaluator { + let left = unary(); + for (;;) { + let t = peek(); + if (t && t.kind === 'op' && (t.value === '*' || t.value === '/')) { + take(); + let right = unary(); + let op = t.value; + let l = left; + left = op === '*' ? (x) => l(x) * right(x) : (x) => l(x) / right(x); + continue; + } + if (startsPrimary(t)) { + // implicit multiplication: 2x, 2sin(x), x(x+1) + let right = unary(); + let l = left; + left = (x) => l(x) * right(x); + continue; + } + return left; + } + } + + function expr(): Evaluator { + let left = term(); + for (;;) { + let t = peek(); + if (t && t.kind === 'op' && (t.value === '+' || t.value === '-')) { + take(); + let right = term(); + let op = t.value; + let l = left; + left = op === '+' ? (x) => l(x) + right(x) : (x) => l(x) - right(x); + continue; + } + return left; + } + } + + let compiled = expr(); + if (failed || pos !== toks.length) { + return null; + } + let probe = compiled(0.5); + if (typeof probe !== 'number') { + return null; + } + return (x: number) => { + let v = compiled(x); + return isFinite(v) ? v : 0; + }; +} + +// ---- spec validation ---- + +const ADD_TYPES = [ + 'ink', + 'axes', + 'plane', + 'graph', + 'circle', + 'square', + 'rect', + 'line', + 'arrow', + 'vector', + 'dot', + 'text', + 'mathtex', +]; +const PLAY_TYPES = [ + 'create', + 'write', + 'fadeIn', + 'fadeOut', + 'transform', + 'rotate', + 'scale', + 'wave', + 'orbit', + 'indicate', + 'shear', + 'wait', +]; + +function asNum(v: unknown, min: number, max: number, dflt: number): number { + let n = Number(v); + return isFinite(n) ? clamp(n, min, max) : dflt; +} + +function asRange(v: unknown, fallback: [number, number]): [number, number] { + if (Array.isArray(v) && v.length >= 2) { + let a = asNum(v[0], -20, 20, fallback[0]); + let b = asNum(v[1], -20, 20, fallback[1]); + return a < b ? [a, b] : fallback; + } + return fallback; +} + +function asPoint(v: unknown): [number, number] | null { + if (Array.isArray(v) && v.length >= 2) { + let x = Number(v[0]); + let y = Number(v[1]); + if (isFinite(x) && isFinite(y)) { + return [clamp(x, -20, 20), clamp(y, -20, 20)]; + } + } + return null; +} + +function asColor(v: unknown): string | null { + if ( + typeof v === 'string' && + /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v) + ) { + return v; + } + if (v === 'accent') { + return 'accent'; + } + return null; +} + +export function validateSceneSpec(raw: unknown): SceneSpec | null { + if (!raw || typeof raw !== 'object') { + return null; + } + let title = String((raw as any).title ?? 'Scene').slice(0, 80); + let steps = (raw as any).steps; + if (!Array.isArray(steps) || !steps.length) { + return null; + } + let out: SceneStep[] = []; + let ids = new Set(); + + for (let step of steps.slice(0, SCENE_MAX_STEPS)) { + if (!step || typeof step !== 'object') { + continue; + } + if (typeof step.add === 'string' && ADD_TYPES.includes(step.add)) { + let id = String(step.id ?? '').slice(0, 24); + if (!id || ids.has(id)) { + continue; + } + let s: SceneAddStep = { add: step.add, id }; + let at = asPoint(step.at); + if (at) s.at = at; + let color = asColor(step.color); + if (color) s.color = color; + switch (step.add) { + case 'axes': + case 'plane': + s.xRange = asRange(step.xRange, [-5, 5]); + s.yRange = asRange(step.yRange, [-3, 3]); + break; + case 'graph': { + let fn = String(step.fn ?? ''); + if (!compileExpression(fn)) { + continue; // unparseable function: drop the step + } + s.fn = fn.slice(0, 120); + s.xRange = asRange(step.xRange, [-5, 5]); + if (typeof step.axes === 'string') { + s.axes = String(step.axes).slice(0, 24); + } + break; + } + case 'circle': + s.radius = asNum(step.radius, 0.1, 12, 1.5); + break; + case 'square': + s.side = asNum(step.side ?? step.sideLength, 0.1, 12, 2); + break; + case 'rect': + s.w = asNum(step.w ?? step.width, 0.1, 14, 3); + s.h = asNum(step.h ?? step.height, 0.1, 10, 2); + break; + case 'line': + case 'arrow': { + let from = asPoint(step.from); + let to = asPoint(step.to); + if (!from || !to) { + continue; + } + s.from = from; + s.to = to; + break; + } + case 'vector': { + let to = asPoint(step.to); + if (!to) { + continue; + } + s.to = to; + break; + } + case 'dot': + s.at = asPoint(step.at) ?? [0, 0]; + s.radius = asNum(step.radius, 0.04, 1, 0.12); + break; + case 'ink': + break; // geometry arrives via spec.ink, injected by the board + case 'text': + case 'mathtex': { + let value = String(step.value ?? '').slice(0, 120); + if (!value.trim()) { + continue; + } + s.value = value; + break; + } + } + ids.add(id); + out.push(s); + continue; + } + if (typeof step.play === 'string' && PLAY_TYPES.includes(step.play)) { + let p: ScenePlayStep = { play: step.play }; + p.duration = asNum(step.duration, 0.2, SCENE_MAX_DURATION, 1); + if (step.play === 'wait') { + out.push(p); + continue; + } + let target = String(step.target ?? '').slice(0, 24); + if (!target || !ids.has(target)) { + continue; // plays must reference something already added + } + p.target = target; + switch (step.play) { + case 'transform': { + let to = String(step.to ?? '').slice(0, 24); + if (!to || !ids.has(to)) { + continue; + } + p.to = to; + break; + } + case 'rotate': + p.angle = asNum(step.angle, -6.4, 6.4, Math.PI); + break; + case 'scale': + p.factor = asNum(step.factor ?? step.scaleFactor, 0.1, 6, 1.5); + break; + case 'wave': + p.amplitude = asNum(step.amplitude, 0.05, 2, 0.4); + break; + case 'orbit': { + let path = String(step.path ?? '').slice(0, 24); + if (!path || !ids.has(path)) { + continue; + } + p.path = path; + break; + } + case 'shear': { + let m = step.matrix; + if ( + !Array.isArray(m) || + m.length !== 2 || + !m.every( + (row: unknown) => + Array.isArray(row) && + row.length === 2 && + row.every((n: unknown) => isFinite(Number(n))), + ) + ) { + continue; + } + p.matrix = m.map((row: number[]) => + row.map((n) => clamp(Number(n), -10, 10)), + ); + break; + } + } + out.push(p); + } + } + + if (!out.some((s) => 'play' in s && s.play !== 'wait')) { + return null; // a scene with nothing animated isn't a scene + } + let hasInk = out.some((s) => 'add' in s && (s as SceneAddStep).add === 'ink'); + let bg = (raw as any).background; + let background: 'paper' | 'screen' = + bg === 'paper' || bg === 'screen' ? bg : hasInk ? 'paper' : 'screen'; + let spec: SceneSpec = { title, steps: out, background }; + // pass through board-injected ink geometry, revalidated on every parse + let rawInk = (raw as any).ink; + if (hasInk && Array.isArray(rawInk)) { + let ink = rawInk + .filter((l: unknown) => Array.isArray(l) && (l as number[]).length >= 4) + .slice(0, 24) + .map((l: number[]) => + l.slice(0, 400).map((n) => clamp(Number(n) || 0, -8, 8)), + ); + if (ink.length) { + spec.ink = ink; + } + } + return spec; +} + +export function mapStrokesToManim(strokes: InkStroke[], box: BBox): number[][] { + // fit the lassoed region into a 10×6 patch of manim units, y flipped + let w = Math.max(1, box.maxX - box.minX); + let h = Math.max(1, box.maxY - box.minY); + let scale = Math.min(10 / w, 6 / h); + let cx = (box.minX + box.maxX) / 2; + let cy = (box.minY + box.maxY) / 2; + return strokes.slice(0, 24).map((s) => { + let out: number[] = []; + for (let i = 0; i + 1 < s.pts.length && out.length < 400; i += 2) { + out.push((s.pts[i] - cx) * scale, -(s.pts[i + 1] - cy) * scale); + } + return out; + }); +} + +export function extractSceneBlock(raw: string): { + rest: string; + spec: SceneSpec | null; +} { + let m = raw.match(/```scene\s*([\s\S]*?)```/); + if (!m) { + return { rest: raw, spec: null }; + } + let rest = raw.replace(m[0], '').trim(); + try { + return { rest, spec: validateSceneSpec(JSON.parse(m[1])) }; + } catch { + return { rest, spec: null }; + } +} + +// the grammar the model writes against — proven verbatim in the v3 spike +export const SCENE_GRAMMAR = [ + 'If motion would explain the idea better than words, append ONE fenced block exactly like ```scene {"title":"...","steps":[...]}``` after your text.', + ' Each step is one JSON object. Object steps: {"add":TYPE,"id":"unique",...props}. TYPE choices:', + ' Prefer composing your own clean, precise graphs and diagrams — that is what makes the animation valuable. ink{} — the user\'s circled strokes as one object — should ONLY be used as the starting shape of a transform into the ideal form you draw (rough sketch morphs into the true curve); never animate the raw ink for its own sake · axes{xRange:[a,b],yRange:[a,b]} · plane{xRange,yRange} · graph{fn:"expr in x",axes:"axesId",xRange:[a,b]} · circle{radius} · square{side} · rect{w,h} · line{from:[x,y],to:[x,y]} · arrow{from,to} · vector{to:[x,y]} · dot{at:[x,y]} · text{value,at} · mathtex{value:"LaTeX",at}.', + ' Optional props: color (a #RGB / #RGBA / #RRGGBB / #RRGGBBAA hex value, or the string "accent" to inherit the board\'s own marker color), at:[x,y]. Coordinates are manim world units, roughly x -6..6, y -3.5..3.5, origin center.', + ' Animation steps: {"play":KIND,"target":"id",...}: create · write · fadeIn · fadeOut · transform{to:"otherId"} · rotate{angle:radians} · scale{factor} · wave{amplitude} · orbit{path:"idOfCircleOrLine"} · indicate · shear{matrix:[[a,b],[c,d]]} · wait{duration}.', + ' Every play may set duration in seconds (max 4). Max 16 steps. fn expressions may use x, sin, cos, tan, exp, log, sqrt, abs, pi — nothing else.', + ' Example: ```scene {"title":"sine forms","steps":[{"add":"axes","id":"ax","xRange":[-5,5],"yRange":[-2,2]},{"add":"graph","id":"g","fn":"sin(x)","axes":"ax"},{"play":"create","target":"g","duration":2},{"play":"wave","target":"g","amplitude":0.4,"duration":2}]}```', +].join('');